feat(我的绩效): 开发我的绩效功能。
fix(加班申请、工作报告): 重构加班申请在审批时的样式,工作报告在新增时的对话框、报告详情页的样式。
This commit is contained in:
860
pnpm-lock.yaml
generated
860
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,7 @@ function renderNodeLabel(node: Api.SystemManage.MySubordinateTreeNode) {
|
||||
:current-node-key="selectedUserId || undefined"
|
||||
:props="{ label: 'userNickname', children: 'children' }"
|
||||
highlight-current
|
||||
default-expand-all
|
||||
:default-expanded-keys="[props.data.userId]"
|
||||
expand-on-click-node
|
||||
class="subordinate-selector__tree"
|
||||
@node-click="handleNodeClick"
|
||||
|
||||
@@ -18,6 +18,7 @@ export type StatusDomain =
|
||||
| 'projectRequirement'
|
||||
| 'workOrder'
|
||||
| 'workReport'
|
||||
| 'performanceSheet'
|
||||
| 'personalItem'
|
||||
| 'overtimeApplication';
|
||||
|
||||
@@ -88,6 +89,13 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
|
||||
approved: 'success',
|
||||
rejected: 'danger'
|
||||
},
|
||||
// 绩效表
|
||||
performanceSheet: {
|
||||
draft: 'info',
|
||||
sent: 'warning',
|
||||
confirmed: 'success',
|
||||
rejected: 'danger'
|
||||
},
|
||||
// 个人事项
|
||||
personalItem: {
|
||||
pending: 'info',
|
||||
|
||||
@@ -193,7 +193,7 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
title: 'personal-center_my-performance',
|
||||
i18nKey: 'route.personal-center_my-performance',
|
||||
icon: 'mdi:trophy-outline',
|
||||
order: 3,
|
||||
order: 4,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './notice';
|
||||
export * from './notify-message';
|
||||
export * from './object-context';
|
||||
export * from './overtime-application';
|
||||
export * from './performance';
|
||||
export * from './personal-item';
|
||||
export * from './product';
|
||||
export * from './project';
|
||||
|
||||
501
src/service/api/performance.ts
Normal file
501
src/service/api/performance.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import {
|
||||
type ServiceRequestResult,
|
||||
mapServiceResult,
|
||||
normalizeNullableStringId,
|
||||
normalizeStringId,
|
||||
safeJsonRequestConfig
|
||||
} from './shared';
|
||||
|
||||
const TEMPLATE_PREFIX = `${WEB_SERVICE_PREFIX}/project/performance-templates`;
|
||||
const SHEET_PREFIX = `${WEB_SERVICE_PREFIX}/project/performance-sheets`;
|
||||
const TEAM_PREFIX = `${SHEET_PREFIX}/team`;
|
||||
|
||||
type StringIdResponse = string | number;
|
||||
|
||||
type TemplateResponse = Omit<Api.Performance.Template.Template, 'id' | 'fileId' | 'uploadUserId' | 'activeFlag'> & {
|
||||
id: StringIdResponse;
|
||||
fileId: StringIdResponse;
|
||||
uploadUserId: StringIdResponse;
|
||||
activeFlag?: boolean | number | string | null;
|
||||
};
|
||||
|
||||
type TemplatePageResponse = {
|
||||
total: number | string;
|
||||
list: TemplateResponse[];
|
||||
};
|
||||
|
||||
type SheetResponse = Omit<
|
||||
Api.Performance.Sheet.Sheet,
|
||||
'id' | 'employeeId' | 'employeeDeptId' | 'managerId' | 'templateId' | 'fileId'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
employeeId: StringIdResponse;
|
||||
employeeDeptId: StringIdResponse;
|
||||
managerId: StringIdResponse;
|
||||
templateId: StringIdResponse;
|
||||
fileId?: StringIdResponse | null;
|
||||
};
|
||||
|
||||
type SheetPageResponse = {
|
||||
total: number | string;
|
||||
list: SheetResponse[];
|
||||
};
|
||||
|
||||
type StatusLogResponse = Omit<Api.Performance.Sheet.StatusLog, 'id' | 'sheetId' | 'operatorUserId'> & {
|
||||
id: StringIdResponse;
|
||||
sheetId: StringIdResponse;
|
||||
operatorUserId: StringIdResponse;
|
||||
};
|
||||
|
||||
type ResponseRecordResponse = Omit<
|
||||
Api.Performance.Sheet.ResponseRecord,
|
||||
'id' | 'sheetId' | 'statusLogId' | 'responderUserId'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
sheetId: StringIdResponse;
|
||||
statusLogId: StringIdResponse;
|
||||
responderUserId: StringIdResponse;
|
||||
};
|
||||
|
||||
type MonthlyResultResponse = Omit<Api.Performance.Sheet.MonthlyResult, 'sheetId' | 'employeeId'> & {
|
||||
sheetId?: StringIdResponse | null;
|
||||
employeeId: StringIdResponse;
|
||||
};
|
||||
|
||||
type TeamSummaryResponse = Omit<
|
||||
Api.Performance.Team.Summary,
|
||||
| 'totalSheetCount'
|
||||
| 'pendingSendCount'
|
||||
| 'pendingConfirmCount'
|
||||
| 'pendingSendUsers'
|
||||
| 'pendingConfirmUsers'
|
||||
| 'deptOrgAverages'
|
||||
> & {
|
||||
totalSheetCount?: number | string | null;
|
||||
pendingSendCount?: number | string | null;
|
||||
pendingConfirmCount?: number | string | null;
|
||||
pendingSendUsers?: Array<
|
||||
Omit<Api.Performance.Team.PendingSendUser, 'userId' | 'managerUserId' | 'sheetId'> & {
|
||||
userId: StringIdResponse;
|
||||
managerUserId: StringIdResponse;
|
||||
sheetId?: StringIdResponse | null;
|
||||
}
|
||||
> | null;
|
||||
pendingConfirmUsers?: Array<
|
||||
Omit<Api.Performance.Team.PendingConfirmUser, 'userId' | 'sheetId'> & {
|
||||
userId: StringIdResponse;
|
||||
sheetId: StringIdResponse;
|
||||
}
|
||||
> | null;
|
||||
deptOrgAverages?: Array<
|
||||
Omit<Api.Performance.Team.DeptOrgAverage, 'deptId' | 'confirmedCount'> & {
|
||||
deptId: StringIdResponse;
|
||||
confirmedCount?: number | string | null;
|
||||
}
|
||||
> | null;
|
||||
};
|
||||
|
||||
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value === 1;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
return !['', '0', 'false', 'n', 'no'].includes(normalized);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeTotal(value: number | string | null | undefined) {
|
||||
const total = Number(value ?? 0);
|
||||
|
||||
return Number.isFinite(total) ? Math.max(0, total) : 0;
|
||||
}
|
||||
|
||||
function normalizeTemplate(response: TemplateResponse): Api.Performance.Template.Template {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
fileId: normalizeStringId(response.fileId),
|
||||
uploadUserId: normalizeStringId(response.uploadUserId),
|
||||
activeFlag: normalizeBooleanFlag(response.activeFlag),
|
||||
remark: response.remark ?? null,
|
||||
scoreCellMapping: response.scoreCellMapping ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSheet(response: SheetResponse): Api.Performance.Sheet.Sheet {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
employeeId: normalizeStringId(response.employeeId),
|
||||
employeeDeptId: normalizeStringId(response.employeeDeptId),
|
||||
managerId: normalizeStringId(response.managerId),
|
||||
templateId: normalizeStringId(response.templateId),
|
||||
fileId: normalizeNullableStringId(response.fileId),
|
||||
fileName: response.fileName ?? null,
|
||||
statusName: response.statusName || response.statusCode,
|
||||
actualScoreTotal: response.actualScoreTotal ?? null,
|
||||
baseScoreTotal: response.baseScoreTotal ?? null,
|
||||
extraScoreTotal: response.extraScoreTotal ?? null,
|
||||
sentTime: response.sentTime ?? null,
|
||||
confirmedTime: response.confirmedTime ?? null,
|
||||
rejectedTime: response.rejectedTime ?? null,
|
||||
lastStatusReason: response.lastStatusReason ?? null,
|
||||
createTime: response.createTime ?? null,
|
||||
updateTime: response.updateTime ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStatusLog(response: StatusLogResponse): Api.Performance.Sheet.StatusLog {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
sheetId: normalizeStringId(response.sheetId),
|
||||
operatorUserId: normalizeStringId(response.operatorUserId),
|
||||
reason: response.reason ?? null,
|
||||
remark: response.remark ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponseRecord(response: ResponseRecordResponse): Api.Performance.Sheet.ResponseRecord {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
sheetId: normalizeStringId(response.sheetId),
|
||||
statusLogId: normalizeStringId(response.statusLogId),
|
||||
responderUserId: normalizeStringId(response.responderUserId),
|
||||
opinion: response.opinion ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMonthlyResult(response: MonthlyResultResponse): Api.Performance.Sheet.MonthlyResult {
|
||||
return {
|
||||
...response,
|
||||
sheetId: normalizeNullableStringId(response.sheetId),
|
||||
employeeId: normalizeStringId(response.employeeId),
|
||||
actualScoreTotal: response.actualScoreTotal ?? null,
|
||||
baseScoreTotal: response.baseScoreTotal ?? null,
|
||||
extraScoreTotal: response.extraScoreTotal ?? null,
|
||||
statusCode: response.statusCode ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTeamSummary(response: TeamSummaryResponse): Api.Performance.Team.Summary {
|
||||
return {
|
||||
...response,
|
||||
totalSheetCount: normalizeTotal(response.totalSheetCount),
|
||||
pendingSendCount: normalizeTotal(response.pendingSendCount),
|
||||
pendingConfirmCount: normalizeTotal(response.pendingConfirmCount),
|
||||
pendingSendUsers: (response.pendingSendUsers || []).map(item => ({
|
||||
...item,
|
||||
userId: normalizeStringId(item.userId),
|
||||
managerUserId: normalizeStringId(item.managerUserId),
|
||||
sheetId: normalizeNullableStringId(item.sheetId),
|
||||
statusCode: item.statusCode ?? null
|
||||
})),
|
||||
pendingConfirmUsers: (response.pendingConfirmUsers || []).map(item => ({
|
||||
...item,
|
||||
userId: normalizeStringId(item.userId),
|
||||
sheetId: normalizeStringId(item.sheetId),
|
||||
sentTime: item.sentTime ?? null
|
||||
})),
|
||||
deptOrgAverages: (response.deptOrgAverages || []).map(item => ({
|
||||
...item,
|
||||
deptId: normalizeStringId(item.deptId),
|
||||
averageScore: item.averageScore ?? null,
|
||||
confirmedCount: normalizeTotal(item.confirmedCount)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function appendValue(query: URLSearchParams, key: string, value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) {
|
||||
query.append(key, '');
|
||||
return;
|
||||
}
|
||||
|
||||
value.forEach(item => appendValue(query, key, item));
|
||||
return;
|
||||
}
|
||||
|
||||
query.append(key, String(value));
|
||||
}
|
||||
|
||||
export function formatToYYYYMM(value?: string | null) {
|
||||
if (!value) return '';
|
||||
|
||||
const d = dayjs(value);
|
||||
|
||||
return d.isValid() ? d.format('YYYY-MM') : value.slice(0, 7);
|
||||
}
|
||||
|
||||
function createSheetQuery(params: Api.Performance.Sheet.SearchParams = {}) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
query.append('pageNo', String(params.pageNo ?? 1));
|
||||
query.append('pageSize', String(params.pageSize ?? 10));
|
||||
appendValue(query, 'employeeIds', params.employeeIds);
|
||||
// 将 periodMonthRange 拆为 periodMonthStart / periodMonthEnd
|
||||
if (params.periodMonthRange?.length === 2) {
|
||||
appendValue(query, 'periodMonthStart', formatToYYYYMM(params.periodMonthRange[0]));
|
||||
appendValue(query, 'periodMonthEnd', formatToYYYYMM(params.periodMonthRange[1]));
|
||||
}
|
||||
// employeeId 单选追加到 employeeIds
|
||||
if (params.employeeId) {
|
||||
query.append('employeeIds', params.employeeId);
|
||||
}
|
||||
appendValue(query, 'employeeDeptId', params.employeeDeptId);
|
||||
appendValue(query, 'managerName', params.managerName);
|
||||
appendValue(query, 'statusCode', params.statusCode);
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
function createTemplateQuery(params: Api.Performance.Template.SearchParams = {}) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
query.append('pageNo', String(params.pageNo ?? 1));
|
||||
query.append('pageSize', String(params.pageSize ?? 10));
|
||||
appendValue(query, 'templateName', params.templateName);
|
||||
appendValue(query, 'activeFlag', params.activeFlag);
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
export async function fetchPerformanceTemplateCurrent() {
|
||||
const result = await request<TemplateResponse | null>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${TEMPLATE_PREFIX}/current`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<TemplateResponse | null>, data =>
|
||||
data ? normalizeTemplate(data) : null
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPerformanceTemplatePage(params: Api.Performance.Template.SearchParams = {}) {
|
||||
const query = createTemplateQuery(params);
|
||||
const result = await request<TemplatePageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: query ? `${TEMPLATE_PREFIX}/page?${query}` : `${TEMPLATE_PREFIX}/page`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<TemplatePageResponse>, data => ({
|
||||
total: normalizeTotal(data.total),
|
||||
list: data.list.map(normalizeTemplate)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function uploadPerformanceTemplate(data: Api.Performance.Template.UploadParams) {
|
||||
const result = await request<StringIdResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${TEMPLATE_PREFIX}/upload`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
|
||||
}
|
||||
|
||||
export function activatePerformanceTemplate(id: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${TEMPLATE_PREFIX}/${id}/activate`,
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPerformanceSheetPage(params: Api.Performance.Sheet.SearchParams = {}) {
|
||||
const query = createSheetQuery(params);
|
||||
const result = await request<SheetPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: query ? `${SHEET_PREFIX}/page?${query}` : `${SHEET_PREFIX}/page`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<SheetPageResponse>, data => ({
|
||||
total: normalizeTotal(data.total),
|
||||
list: data.list.map(normalizeSheet)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchPerformanceSheet(id: string) {
|
||||
const result = await request<SheetResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/${id}`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<SheetResponse>, normalizeSheet);
|
||||
}
|
||||
|
||||
export async function createPerformanceSheet(data: Api.Performance.Sheet.CreateParams) {
|
||||
const result = await request<StringIdResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: SHEET_PREFIX,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
|
||||
}
|
||||
|
||||
export function updatePerformanceSheetExcel(id: string, data: Api.Performance.Sheet.ExcelUpdateParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/${id}/excel`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePerformanceSheet(id: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/${id}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
export function sendPerformanceSheet(id: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/${id}/send`,
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
export function resendPerformanceSheet(id: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/${id}/resend`,
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmPerformanceSheet(id: string, data: Api.Performance.Sheet.StatusActionParams = {}) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/${id}/confirm`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectPerformanceSheet(id: string, data: Api.Performance.Sheet.StatusActionParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/${id}/reject`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadPerformanceSheet(id: string) {
|
||||
return request<Blob, 'blob'>({
|
||||
url: `${SHEET_PREFIX}/${id}/download`,
|
||||
method: 'get',
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
|
||||
export function batchDownloadPerformanceSheets(data: Api.Performance.Sheet.BatchDownloadParams) {
|
||||
return request<Blob, 'blob'>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/batch-download`,
|
||||
method: 'post',
|
||||
data,
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
|
||||
export function exportPerformanceSheets(params: Api.Performance.Sheet.SearchParams = {}) {
|
||||
const query = createSheetQuery(params);
|
||||
|
||||
return request<Blob, 'blob'>({
|
||||
url: query ? `${SHEET_PREFIX}/export?${query}` : `${SHEET_PREFIX}/export`,
|
||||
method: 'get',
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPerformanceSheetStatusLogs(id: string) {
|
||||
const result = await request<StatusLogResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/${id}/status-logs`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<StatusLogResponse[]>, data => data.map(normalizeStatusLog));
|
||||
}
|
||||
|
||||
export async function fetchPerformanceSheetResponseRecords(id: string) {
|
||||
const result = await request<ResponseRecordResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/${id}/response-records`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ResponseRecordResponse[]>, data =>
|
||||
data.map(normalizeResponseRecord)
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPerformanceMonthlyResult(employeeId: string, periodMonth: string) {
|
||||
const result = await request<MonthlyResultResponse | null>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/monthly-result`,
|
||||
method: 'get',
|
||||
params: { employeeId, periodMonth }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<MonthlyResultResponse | null>, data =>
|
||||
data ? normalizeMonthlyResult(data) : null
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchPerformanceSheetStatusDict() {
|
||||
return request<Api.Performance.Sheet.StatusDict[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/status-dict`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchPerformanceSheetStatusTransitions() {
|
||||
return request<Api.Performance.Sheet.StatusTransition[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${SHEET_PREFIX}/status-transitions`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTeamPerformanceSummary(params: Api.Performance.Team.SummaryParams = {}) {
|
||||
const result = await request<TeamSummaryResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${TEAM_PREFIX}/summary`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<TeamSummaryResponse>, normalizeTeamSummary);
|
||||
}
|
||||
|
||||
export function remindTeamPerformance(data: Api.Performance.Team.RemindParams) {
|
||||
return request<Api.Performance.Team.RemindResult>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${TEAM_PREFIX}/remind`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
231
src/typings/api/performance.d.ts
vendored
Normal file
231
src/typings/api/performance.d.ts
vendored
Normal file
@@ -0,0 +1,231 @@
|
||||
declare namespace Api {
|
||||
namespace Performance {
|
||||
namespace Common {
|
||||
interface PageParams {
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface PageResult<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
|
||||
type SheetStatusCode = 'draft' | 'sent' | 'confirmed' | 'rejected' | string;
|
||||
type SheetActionCode = 'send' | 'resend' | 'confirm' | 'reject' | string;
|
||||
type RemindType = 'pending_confirm' | 'pending_send';
|
||||
}
|
||||
|
||||
namespace Template {
|
||||
interface ScoreCellMapping {
|
||||
actualScoreTotalCell?: string | null;
|
||||
baseScoreTotalCell?: string | null;
|
||||
extraScoreTotalCell?: string | null;
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
templateName: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
versionNo: number;
|
||||
activeFlag: boolean;
|
||||
uploadUserId: string;
|
||||
uploadUserName: string;
|
||||
uploadTime: string;
|
||||
remark?: string | null;
|
||||
scoreCellMapping?: ScoreCellMapping | null;
|
||||
}
|
||||
|
||||
type SearchParams = CommonType.RecordNullable<
|
||||
Common.PageParams & {
|
||||
templateName: string;
|
||||
activeFlag: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
interface UploadParams {
|
||||
templateName: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
activeFlag?: boolean | null;
|
||||
remark?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
namespace Sheet {
|
||||
interface Sheet {
|
||||
id: string;
|
||||
periodMonth: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
employeeDeptId: string;
|
||||
employeeDeptName: string;
|
||||
deptOrgType: string;
|
||||
managerId: string;
|
||||
managerName: string;
|
||||
templateId: string;
|
||||
fileId?: string | null;
|
||||
fileName?: string | null;
|
||||
fileVersion: number;
|
||||
statusCode: Common.SheetStatusCode;
|
||||
statusName: string;
|
||||
actualScoreTotal?: string | number | null;
|
||||
baseScoreTotal?: string | number | null;
|
||||
extraScoreTotal?: string | number | null;
|
||||
sentTime?: string | null;
|
||||
confirmedTime?: string | null;
|
||||
rejectedTime?: string | null;
|
||||
lastStatusReason?: string | null;
|
||||
createTime?: string | null;
|
||||
updateTime?: string | null;
|
||||
}
|
||||
|
||||
type SearchParams = CommonType.RecordNullable<
|
||||
Common.PageParams & {
|
||||
employeeIds: string[];
|
||||
periodMonthRange: string[];
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
employeeDeptId: string;
|
||||
employeeDeptName: string;
|
||||
managerId: string;
|
||||
managerName: string;
|
||||
statusCode: Common.SheetStatusCode;
|
||||
}
|
||||
>;
|
||||
|
||||
interface CreateParams {
|
||||
periodMonth: string;
|
||||
employeeId: string;
|
||||
}
|
||||
|
||||
interface ExcelUpdateParams {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileVersion: number;
|
||||
actualScoreTotal: string | number;
|
||||
baseScoreTotal: string | number;
|
||||
extraScoreTotal: string | number;
|
||||
}
|
||||
|
||||
interface StatusActionParams {
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface BatchDownloadParams {
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
interface StatusDict {
|
||||
statusCode: Common.SheetStatusCode;
|
||||
statusName: string;
|
||||
sort: number;
|
||||
initialFlag: boolean;
|
||||
terminalFlag: boolean;
|
||||
allowEdit: boolean;
|
||||
}
|
||||
|
||||
interface StatusTransition {
|
||||
actionCode: Common.SheetActionCode;
|
||||
actionName: string;
|
||||
fromStatusCode: Common.SheetStatusCode;
|
||||
toStatusCode: Common.SheetStatusCode;
|
||||
needReason: boolean;
|
||||
}
|
||||
|
||||
interface StatusLog {
|
||||
id: string;
|
||||
sheetId: string;
|
||||
actionType: Common.SheetActionCode;
|
||||
fromStatus?: Common.SheetStatusCode | null;
|
||||
toStatus?: Common.SheetStatusCode | null;
|
||||
reason?: string | null;
|
||||
operatorUserId: string;
|
||||
operatorName: string;
|
||||
periodMonthSnapshot: string;
|
||||
employeeNameSnapshot: string;
|
||||
remark?: string | null;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
interface ResponseRecord {
|
||||
id: string;
|
||||
sheetId: string;
|
||||
statusLogId: string;
|
||||
roundNo: number;
|
||||
actionType: Common.SheetActionCode;
|
||||
fromStatus: Common.SheetStatusCode;
|
||||
toStatus: Common.SheetStatusCode;
|
||||
opinion?: string | null;
|
||||
responderUserId: string;
|
||||
responderName: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
interface MonthlyResult {
|
||||
sheetId?: string | null;
|
||||
periodMonth: string;
|
||||
employeeId: string;
|
||||
actualScoreTotal?: string | number | null;
|
||||
baseScoreTotal?: string | number | null;
|
||||
extraScoreTotal?: string | number | null;
|
||||
statusCode?: Common.SheetStatusCode | null;
|
||||
}
|
||||
}
|
||||
|
||||
namespace Team {
|
||||
interface SummaryParams {
|
||||
periodMonthStart?: string | null;
|
||||
periodMonthEnd?: string | null;
|
||||
}
|
||||
|
||||
interface PendingSendUser {
|
||||
userId: string;
|
||||
userNickname: string;
|
||||
managerUserId: string;
|
||||
managerName: string;
|
||||
sheetId?: string | null;
|
||||
statusCode?: Common.SheetStatusCode | null;
|
||||
}
|
||||
|
||||
interface PendingConfirmUser {
|
||||
userId: string;
|
||||
userNickname: string;
|
||||
sheetId: string;
|
||||
sentTime?: string | null;
|
||||
}
|
||||
|
||||
interface DeptOrgAverage {
|
||||
deptId: string;
|
||||
deptName: string;
|
||||
deptOrgType: string;
|
||||
averageScore?: string | number | null;
|
||||
confirmedCount: number;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
periodMonthStart: string;
|
||||
periodMonthEnd: string;
|
||||
totalSheetCount: number;
|
||||
pendingSendCount: number;
|
||||
pendingConfirmCount: number;
|
||||
confirmedRate: string | number;
|
||||
pendingSendUsers: PendingSendUser[];
|
||||
pendingConfirmUsers: PendingConfirmUser[];
|
||||
deptOrgAverages: DeptOrgAverage[];
|
||||
}
|
||||
|
||||
interface RemindParams {
|
||||
periodMonthStart?: string | null;
|
||||
periodMonthEnd?: string | null;
|
||||
remindType: Common.RemindType;
|
||||
userIds?: string[] | null;
|
||||
}
|
||||
|
||||
interface RemindResult {
|
||||
remindedCount: number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/typings/components.d.ts
vendored
46
src/typings/components.d.ts
vendored
@@ -16,7 +16,6 @@ declare module 'vue' {
|
||||
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default']
|
||||
BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.vue')['default']
|
||||
BusinessFormSection: typeof import('./../components/custom/business-form-section.vue')['default']
|
||||
BusinessFormSimpleDialog: typeof import('./../components/custom/business-form-simple-dialog.vue')['default']
|
||||
BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default']
|
||||
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
|
||||
BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default']
|
||||
@@ -35,17 +34,13 @@ declare module 'vue' {
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDatePickerPanel: typeof import('element-plus/es')['ElDatePickerPanel']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
@@ -78,7 +73,6 @@ declare module 'vue' {
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSpace: typeof import('element-plus/es')['ElSpace']
|
||||
ElStatistic: typeof import('element-plus/es')['ElStatistic']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
@@ -86,11 +80,10 @@ declare module 'vue' {
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
ElWatermark: typeof import('element-plus/es')['ElWatermark']
|
||||
ExceptionBase: typeof import('./../components/common/exception-base.vue')['default']
|
||||
FullScreen: typeof import('./../components/common/full-screen.vue')['default']
|
||||
@@ -98,10 +91,6 @@ declare module 'vue' {
|
||||
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
||||
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||
IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default']
|
||||
IconCarbonAdd: typeof import('~icons/carbon/add')['default']
|
||||
IconCarbonPlay: typeof import('~icons/carbon/play')['default']
|
||||
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
|
||||
'IconCharm:download': typeof import('~icons/charm/download')['default']
|
||||
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
|
||||
'IconEp:arrowRight': typeof import('~icons/ep/arrow-right')['default']
|
||||
'IconEp:box': typeof import('~icons/ep/box')['default']
|
||||
@@ -113,53 +102,32 @@ declare module 'vue' {
|
||||
'IconEp:sort': typeof import('~icons/ep/sort')['default']
|
||||
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
|
||||
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
|
||||
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
|
||||
'IconF7:flagCircleFill': typeof import('~icons/f7/flag-circle-fill')['default']
|
||||
'IconFe:eye': typeof import('~icons/fe/eye')['default']
|
||||
'IconFe:question': typeof import('~icons/fe/question')['default']
|
||||
'IconFileIcons:microsoftExcel': typeof import('~icons/file-icons/microsoft-excel')['default']
|
||||
'IconGg:ratio': typeof import('~icons/gg/ratio')['default']
|
||||
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default']
|
||||
'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['default']
|
||||
IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default']
|
||||
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
|
||||
IconIcRoundFolder: typeof import('~icons/ic/round-folder')['default']
|
||||
IconIcRoundInventory2: typeof import('~icons/ic/round-inventory2')['default']
|
||||
IconIcRoundPackage2: typeof import('~icons/ic/round-package2')['default']
|
||||
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
|
||||
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
|
||||
IconIcRoundRocketLaunch: typeof import('~icons/ic/round-rocket-launch')['default']
|
||||
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
|
||||
IconIcRoundUnfoldLess: typeof import('~icons/ic/round-unfold-less')['default']
|
||||
IconIcRoundUnfoldMore: typeof import('~icons/ic/round-unfold-more')['default']
|
||||
IconLocalActivity: typeof import('~icons/local/activity')['default']
|
||||
IconLocalBanner: typeof import('~icons/local/banner')['default']
|
||||
IconLocalCast: typeof import('~icons/local/cast')['default']
|
||||
IconLocalLogo: typeof import('~icons/local/logo')['default']
|
||||
'IconMaterialSymbolsLight:rotate90DegreesCcwOutlineRounded': typeof import('~icons/material-symbols-light/rotate90-degrees-ccw-outline-rounded')['default']
|
||||
IconMaterialSymbolsLightCheckCircleRounded: typeof import('~icons/material-symbols-light/check-circle-rounded')['default']
|
||||
IconMaterialSymbolsPackage2: typeof import('~icons/material-symbols/package2')['default']
|
||||
'IconMdi:paperclip': typeof import('~icons/mdi/paperclip')['default']
|
||||
'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
|
||||
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']
|
||||
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
|
||||
IconMdiCheck: typeof import('~icons/mdi/check')['default']
|
||||
IconMdiCheckCircleOutline: typeof import('~icons/mdi/check-circle-outline')['default']
|
||||
IconMdiChevronDoubleDown: typeof import('~icons/mdi/chevron-double-down')['default']
|
||||
IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default']
|
||||
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
|
||||
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
|
||||
IconMdiClose: typeof import('~icons/mdi/close')['default']
|
||||
IconMdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
|
||||
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
|
||||
IconMdiCloseCircleOutline: typeof import('~icons/mdi/close-circle-outline')['default']
|
||||
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
|
||||
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
|
||||
IconMdiDownload: typeof import('~icons/mdi/download')['default']
|
||||
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
||||
IconMdiFileCogOutline: typeof import('~icons/mdi/file-cog-outline')['default']
|
||||
IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default']
|
||||
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
|
||||
IconMdiFolderOutline: typeof import('~icons/mdi/folder-outline')['default']
|
||||
@@ -167,18 +135,16 @@ declare module 'vue' {
|
||||
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||
IconMdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
|
||||
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
|
||||
IconMdiPackageDown: typeof import('~icons/mdi/package-down')['default']
|
||||
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
|
||||
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
|
||||
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
||||
'IconMingcute:zoomInLine': typeof import('~icons/mingcute/zoom-in-line')['default']
|
||||
'IconMingcute:zoomOutLine': typeof import('~icons/mingcute/zoom-out-line')['default']
|
||||
IconMdiUpload: typeof import('~icons/mdi/upload')['default']
|
||||
IconUilSearch: typeof import('~icons/uil/search')['default']
|
||||
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
||||
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
|
||||
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||
PrioritySelect: typeof import('../views/product/requirement/modules/priority-select.vue')['default']
|
||||
ReadonlyField: typeof import('./../components/custom/readonly-field.vue')['default']
|
||||
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
|
||||
@@ -1,3 +1,761 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, markRaw, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElMessageBox, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
batchDownloadPerformanceSheets,
|
||||
deletePerformanceSheet,
|
||||
downloadPerformanceSheet,
|
||||
exportPerformanceSheets,
|
||||
fetchGetDeptSimpleList,
|
||||
fetchGetMySubordinateTree,
|
||||
fetchPerformanceSheetPage,
|
||||
fetchTeamPerformanceSummary,
|
||||
formatToYYYYMM,
|
||||
resendPerformanceSheet,
|
||||
sendPerformanceSheet
|
||||
} from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
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 PerformanceActionDialog from './modules/performance-action-dialog.vue';
|
||||
import PerformanceExcelEditorDrawer from './modules/performance-excel-editor-drawer.vue';
|
||||
import PerformanceRecordDialog from './modules/performance-record-dialog.vue';
|
||||
import PerformanceSearch from './modules/performance-search.vue';
|
||||
import PerformanceSummary from './modules/performance-summary.vue';
|
||||
import PerformanceTemplateDialog from './modules/performance-template-dialog.vue';
|
||||
import {
|
||||
PerformancePermission,
|
||||
createDefaultPeriodMonth,
|
||||
downloadBlob,
|
||||
formatDateTime,
|
||||
formatScore,
|
||||
getPerformanceStatusLabel,
|
||||
getSheetExportName,
|
||||
resolvePerformanceStatusTagType
|
||||
} from './modules/performance-shared';
|
||||
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiDownloadOutline from '~icons/mdi/download-outline';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiFileDocumentEditOutline from '~icons/mdi/file-document-edit-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiSendOutline from '~icons/mdi/send-outline';
|
||||
|
||||
defineOptions({ name: 'MyPerformance' });
|
||||
|
||||
type PerformanceSheetPageResponse = Awaited<ReturnType<typeof fetchPerformanceSheetPage>>;
|
||||
|
||||
function createSearchParams(): Api.Performance.Sheet.SearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
employeeIds: undefined,
|
||||
periodMonthRange: undefined,
|
||||
employeeId: undefined,
|
||||
employeeName: undefined,
|
||||
employeeDeptId: undefined,
|
||||
employeeDeptName: undefined,
|
||||
managerId: undefined,
|
||||
managerName: undefined,
|
||||
statusCode: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: PerformanceSheetPageResponse, pageNo: number, pageSize: number) {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
const searchParams = reactive(createSearchParams());
|
||||
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.Performance.Team.Summary | null>(null);
|
||||
const selectedRows = ref<Api.Performance.Sheet.Sheet[]>([]);
|
||||
const currentRow = ref<Api.Performance.Sheet.Sheet | null>(null);
|
||||
const deptOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
|
||||
const templateVisible = ref(false);
|
||||
const excelVisible = ref(false);
|
||||
const excelMode = ref<'view' | 'edit' | 'create'>('view');
|
||||
const actionVisible = ref(false);
|
||||
const actionType = ref<'confirm' | 'reject'>('confirm');
|
||||
const recordVisible = ref(false);
|
||||
|
||||
const exporting = ref(false);
|
||||
const rowActionLoadingKey = ref('');
|
||||
|
||||
const ACTION_ICON_MAP = {
|
||||
view: markRaw(IconMdiEyeOutline),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
send: markRaw(IconMdiSendOutline),
|
||||
confirm: markRaw(IconMdiCheckCircleOutline),
|
||||
reject: markRaw(IconMdiCloseCircleOutline),
|
||||
export: markRaw(IconMdiDownloadOutline),
|
||||
delete: markRaw(IconMdiDeleteOutline),
|
||||
response: markRaw(IconMdiFileDocumentEditOutline)
|
||||
};
|
||||
|
||||
const canUseTeamDashboard = computed(() => hasAuth(PerformancePermission.TeamDashboard));
|
||||
const canCreate = computed(() => hasAuth(PerformancePermission.SheetCreate));
|
||||
const canUpdate = computed(() => hasAuth(PerformancePermission.SheetUpdate));
|
||||
const canDelete = computed(() => hasAuth(PerformancePermission.SheetDelete));
|
||||
const canConfirm = computed(() => hasAuth(PerformancePermission.SheetConfirm));
|
||||
const canReject = computed(() => hasAuth(PerformancePermission.SheetReject));
|
||||
const canExport = computed(() => hasAuth(PerformancePermission.SheetExport));
|
||||
const canManageTemplate = computed(
|
||||
() => hasAuth(PerformancePermission.TemplateQuery) || hasAuth(PerformancePermission.TemplateUpdate)
|
||||
);
|
||||
const isTeamMode = computed(() => teamViewMode.value === 'team');
|
||||
const allSubordinateUserIds = computed(() => collectSubordinateUserIds(subordinateTree.value));
|
||||
const subordinateOptions = computed(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
const walk = (nodes?: Api.SystemManage.MySubordinateTreeNode[] | null) => {
|
||||
nodes?.forEach(node => {
|
||||
options.push({ label: node.userNickname, value: node.userId });
|
||||
walk(node.children ?? null);
|
||||
});
|
||||
};
|
||||
|
||||
walk(subordinateTree.value?.children ?? null);
|
||||
return options;
|
||||
});
|
||||
const selectedSubordinateNode = computed(() =>
|
||||
findSubordinateNode(subordinateTree.value, selectedSubordinateUserId.value)
|
||||
);
|
||||
const formattedPeriodMonthStart = computed(
|
||||
() => formatToYYYYMM(searchParams.periodMonthRange?.[0]) || createDefaultPeriodMonth()
|
||||
);
|
||||
const formattedPeriodMonthEnd = computed(
|
||||
() => formatToYYYYMM(searchParams.periodMonthRange?.[1]) || createDefaultPeriodMonth()
|
||||
);
|
||||
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 currentEmployeeIds = computed(() => {
|
||||
if (!isTeamMode.value) return undefined;
|
||||
if (isRootSelected.value) return [];
|
||||
|
||||
return teamContext.value?.selectedUserIds ?? [];
|
||||
});
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
PerformanceSheetPageResponse,
|
||||
Api.Performance.Sheet.Sheet
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () =>
|
||||
fetchPerformanceSheetPage({
|
||||
...searchParams,
|
||||
employeeIds: currentEmployeeIds.value
|
||||
}),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{ prop: 'periodMonth', label: '绩效月份', minWidth: 110 },
|
||||
{ prop: 'employeeName', label: '员工', minWidth: 110, showOverflowTooltip: true },
|
||||
{ prop: 'employeeDeptName', label: '部门', minWidth: 110, showOverflowTooltip: true },
|
||||
{ prop: 'managerName', label: '直属上级', minWidth: 110, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
minWidth: 100,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElTag type={resolvePerformanceStatusTagType(row.statusCode)}>
|
||||
{getPerformanceStatusLabel(row.statusCode, row.statusName)}
|
||||
</ElTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'actualScoreTotal',
|
||||
label: '实际得分',
|
||||
minWidth: 100,
|
||||
formatter: row => formatScore(row.actualScoreTotal)
|
||||
},
|
||||
// {
|
||||
// prop: 'baseScoreTotal',
|
||||
// label: '基础得分',
|
||||
// width: 100,
|
||||
// align: 'right',
|
||||
// formatter: row => formatScore(row.baseScoreTotal)
|
||||
// },
|
||||
// {
|
||||
// prop: 'extraScoreTotal',
|
||||
// label: '附加得分',
|
||||
// width: 100,
|
||||
// align: 'right',
|
||||
// formatter: row => formatScore(row.extraScoreTotal)
|
||||
// },
|
||||
{
|
||||
prop: 'sentTime',
|
||||
label: '发送时间',
|
||||
minWidth: 150,
|
||||
formatter: row => formatDateTime(row.sentTime)
|
||||
},
|
||||
{
|
||||
prop: 'confirmedTime',
|
||||
label: '确认时间',
|
||||
minWidth: 150,
|
||||
formatter: row => formatDateTime(row.confirmedTime)
|
||||
},
|
||||
// {
|
||||
// prop: 'updateTime',
|
||||
// label: '更新时间',
|
||||
// width: 150,
|
||||
// formatter: row => formatDateTime(row.updateTime)
|
||||
// },
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 190,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const totalCount = computed(() => mobilePagination.value.total || data.value.length);
|
||||
|
||||
function getRowActions(row: Api.Performance.Sheet.Sheet): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.view,
|
||||
onClick: () => openExcel(row, 'view')
|
||||
}
|
||||
];
|
||||
|
||||
if (canExport.value && row.fileId) {
|
||||
actions.push({
|
||||
key: 'export',
|
||||
label: '导出',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.export,
|
||||
disabled: rowActionLoadingKey.value === `export:${row.id}`,
|
||||
onClick: () => handleDownload(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (isTeamMode.value) {
|
||||
if (canUpdate.value && ['draft', 'rejected'].includes(row.statusCode)) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.edit,
|
||||
onClick: () => openExcel(row, 'edit')
|
||||
});
|
||||
actions.push({
|
||||
key: row.statusCode === 'rejected' ? 'resend' : 'send',
|
||||
label: row.statusCode === 'rejected' ? '重新发送' : '发送',
|
||||
buttonType: 'success',
|
||||
icon: ACTION_ICON_MAP.send,
|
||||
disabled: rowActionLoadingKey.value === `send:${row.id}`,
|
||||
onClick: () => handleSend(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (canDelete.value && row.statusCode === 'draft') {
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
buttonType: 'danger',
|
||||
icon: ACTION_ICON_MAP.delete,
|
||||
disabled: rowActionLoadingKey.value === `delete:${row.id}`,
|
||||
onClick: () => handleDelete(row)
|
||||
});
|
||||
}
|
||||
} else if (row.statusCode === 'sent') {
|
||||
if (canConfirm.value) {
|
||||
actions.push({
|
||||
key: 'confirm',
|
||||
label: '确认',
|
||||
buttonType: 'success',
|
||||
icon: ACTION_ICON_MAP.confirm,
|
||||
onClick: () => openAction(row, 'confirm')
|
||||
});
|
||||
}
|
||||
if (canReject.value) {
|
||||
actions.push({
|
||||
key: 'reject',
|
||||
label: '退回',
|
||||
buttonType: 'danger',
|
||||
icon: ACTION_ICON_MAP.reject,
|
||||
onClick: () => openAction(row, 'reject')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
actions.push({
|
||||
key: 'response-record',
|
||||
label: '反馈历史',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.response,
|
||||
onClick: () => openRecord(row)
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
const pageSize = searchParams.pageSize ?? 10;
|
||||
Object.assign(searchParams, createSearchParams(), { pageSize });
|
||||
reloadTable(1);
|
||||
loadTeamSummary();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
reloadTable(1);
|
||||
loadTeamSummary();
|
||||
}
|
||||
|
||||
async function reloadTable(page = searchParams.pageNo ?? 1) {
|
||||
await getDataByPage(page);
|
||||
}
|
||||
|
||||
function createExportParams(): Api.Performance.Sheet.SearchParams {
|
||||
const { pageNo: _pageNo, pageSize: _pageSize, ...params } = searchParams;
|
||||
|
||||
return {
|
||||
...params,
|
||||
employeeIds: currentEmployeeIds.value ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: Api.Performance.Sheet.Sheet[]) {
|
||||
selectedRows.value = rows;
|
||||
}
|
||||
|
||||
function openExcel(row: Api.Performance.Sheet.Sheet, mode: 'view' | 'edit') {
|
||||
currentRow.value = row;
|
||||
excelMode.value = mode;
|
||||
excelVisible.value = true;
|
||||
}
|
||||
|
||||
function openCreateExcel() {
|
||||
currentRow.value = null;
|
||||
excelMode.value = 'create';
|
||||
excelVisible.value = true;
|
||||
}
|
||||
|
||||
function openAction(row: Api.Performance.Sheet.Sheet, type: 'confirm' | 'reject') {
|
||||
currentRow.value = row;
|
||||
actionType.value = type;
|
||||
actionVisible.value = true;
|
||||
}
|
||||
|
||||
function openRecord(row: Api.Performance.Sheet.Sheet) {
|
||||
currentRow.value = row;
|
||||
recordVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDownload(row: Api.Performance.Sheet.Sheet) {
|
||||
rowActionLoadingKey.value = `export:${row.id}`;
|
||||
const { error, data: blob } = await downloadPerformanceSheet(row.id);
|
||||
rowActionLoadingKey.value = '';
|
||||
|
||||
if (error || !blob) return;
|
||||
|
||||
downloadBlob(blob, getSheetExportName(row));
|
||||
}
|
||||
|
||||
async function handleExportSelected() {
|
||||
if (!selectedRows.value.length) {
|
||||
window.$message?.warning('请选择要导出的绩效表');
|
||||
return;
|
||||
}
|
||||
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await batchDownloadPerformanceSheets({
|
||||
ids: selectedRows.value.map(item => item.id)
|
||||
});
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) return;
|
||||
|
||||
downloadBlob(blob, `绩效表_导出选中_${dayjs().format('YYYY-MM-DD')}.zip`);
|
||||
}
|
||||
|
||||
async function handleExportAll() {
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await exportPerformanceSheets(createExportParams());
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) return;
|
||||
|
||||
downloadBlob(blob, `绩效表_导出全部_${dayjs().format('YYYY-MM-DD')}.zip`);
|
||||
}
|
||||
|
||||
async function handleExportCommand(command: 'selected' | 'all') {
|
||||
if (command === 'selected') {
|
||||
await handleExportSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
await handleExportAll();
|
||||
}
|
||||
|
||||
async function handleSend(row: Api.Performance.Sheet.Sheet) {
|
||||
if (!row.fileId) {
|
||||
window.$message?.warning('请先保存绩效 Excel 后再发送');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionText = row.statusCode === 'rejected' ? '重新发送' : '发送';
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${actionText}${row.employeeName}的绩效表吗?`, `${actionText}确认`, {
|
||||
type: 'warning',
|
||||
confirmButtonText: `确认${actionText}`,
|
||||
cancelButtonText: '取消'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
rowActionLoadingKey.value = `send:${row.id}`;
|
||||
const result =
|
||||
row.statusCode === 'rejected' ? await resendPerformanceSheet(row.id) : await sendPerformanceSheet(row.id);
|
||||
rowActionLoadingKey.value = '';
|
||||
|
||||
if (result.error) return;
|
||||
|
||||
window.$message?.success(`绩效表已${actionText}`);
|
||||
await reloadAfterMutation();
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.Performance.Sheet.Sheet) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除${row.employeeName}的 ${row.periodMonth} 绩效表吗?`, '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
rowActionLoadingKey.value = `delete:${row.id}`;
|
||||
const { error } = await deletePerformanceSheet(row.id);
|
||||
rowActionLoadingKey.value = '';
|
||||
|
||||
if (error) return;
|
||||
|
||||
window.$message?.success('绩效表已删除');
|
||||
await reloadAfterMutation();
|
||||
}
|
||||
|
||||
async function reloadAfterMutation() {
|
||||
await reloadTable(searchParams.pageNo ?? 1);
|
||||
await loadTeamSummary();
|
||||
}
|
||||
|
||||
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 fetchTeamPerformanceSummary({
|
||||
periodMonthStart: formattedPeriodMonthStart.value,
|
||||
periodMonthEnd: formattedPeriodMonthEnd.value
|
||||
});
|
||||
teamSummaryLoading.value = false;
|
||||
|
||||
teamSummary.value = error || !summaryData ? null : summaryData;
|
||||
}
|
||||
|
||||
async function loadDeptOptions() {
|
||||
const { error, data: deptList } = await fetchGetDeptSimpleList();
|
||||
|
||||
if (error || !deptList) {
|
||||
deptOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
const walk = (nodes: Api.SystemManage.DeptSimple[]) => {
|
||||
nodes.forEach(node => {
|
||||
options.push({ label: node.name, value: String(node.id) });
|
||||
if (node.children) walk(node.children);
|
||||
});
|
||||
};
|
||||
walk(deptList);
|
||||
deptOptions.value = options;
|
||||
}
|
||||
|
||||
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);
|
||||
await loadTeamSummary();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [teamViewMode.value, selectedSubordinateUserId.value],
|
||||
async () => {
|
||||
await reloadTable(1);
|
||||
await loadTeamSummary();
|
||||
}
|
||||
);
|
||||
|
||||
watch(excelVisible, isVisible => {
|
||||
if (isVisible) return;
|
||||
|
||||
currentRow.value = null;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDeptOptions();
|
||||
|
||||
if (canUseTeamDashboard.value) {
|
||||
await loadSubordinateTree();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LookForward title="我的绩效" subtitle="功能建设中,敬请期待" />
|
||||
<div class="my-performance-page">
|
||||
<TeamContextPanel
|
||||
v-if="canUseTeamDashboard"
|
||||
v-model:mode="teamViewMode"
|
||||
:loading="subordinateTreeLoading"
|
||||
:selected-label="selectedTeamLabel"
|
||||
:subordinate-count="subordinateTree?.subordinateCount || 0"
|
||||
@update:mode="handleTeamViewModeChange"
|
||||
>
|
||||
<PerformanceSummary
|
||||
v-if="isRootSelected"
|
||||
:period-month-start="formattedPeriodMonthStart"
|
||||
:period-month-end="formattedPeriodMonthEnd"
|
||||
:loading="teamSummaryLoading"
|
||||
:summary="teamSummary"
|
||||
@reminded="loadTeamSummary"
|
||||
/>
|
||||
</TeamContextPanel>
|
||||
|
||||
<div class="my-performance-page__content" :class="{ 'my-performance-page__content--team': isTeamMode }">
|
||||
<div v-if="canUseTeamDashboard && isTeamMode" class="my-performance-page__sidebar">
|
||||
<SubordinateSelector
|
||||
v-model:selected-user-id="selectedSubordinateUserId"
|
||||
:loading="subordinateTreeLoading"
|
||||
:data="subordinateTree"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-performance-page__main">
|
||||
<PerformanceSearch
|
||||
v-model:model="searchParams"
|
||||
:subordinate-options="subordinateOptions"
|
||||
:dept-options="deptOptions"
|
||||
@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">{{ isTeamMode ? '团队绩效' : '我的绩效' }}</p>
|
||||
<ElTag effect="plain">{{ totalCount }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
<template #default>
|
||||
<ElDropdown v-if="canExport" trigger="click" @command="handleExportCommand">
|
||||
<ElButton plain :loading="exporting">
|
||||
<template #icon>
|
||||
<icon-mdi-download class="text-icon" />
|
||||
</template>
|
||||
导出
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="selected" :disabled="exporting || !selectedRows.length">
|
||||
导出选中
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem command="all" :disabled="exporting">导出全部</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<ElButton v-if="canManageTemplate" plain @click="templateVisible = true">
|
||||
<template #icon>
|
||||
<icon-mdi-file-cog-outline class="text-icon" />
|
||||
</template>
|
||||
模板
|
||||
</ElButton>
|
||||
<ElButton v-if="isTeamMode && canCreate" plain type="primary" @click="openCreateExcel">
|
||||
<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"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<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>
|
||||
|
||||
<PerformanceTemplateDialog v-model:visible="templateVisible" @updated="reloadAfterMutation" />
|
||||
|
||||
<PerformanceExcelEditorDrawer
|
||||
v-model:visible="excelVisible"
|
||||
:row-data="currentRow"
|
||||
:mode="excelMode"
|
||||
:subordinate-options="subordinateOptions"
|
||||
@saved="reloadAfterMutation"
|
||||
@saved-and-sent="reloadAfterMutation"
|
||||
/>
|
||||
|
||||
<PerformanceActionDialog
|
||||
v-model:visible="actionVisible"
|
||||
:row-data="currentRow"
|
||||
:action-type="actionType"
|
||||
@submitted="reloadAfterMutation"
|
||||
/>
|
||||
|
||||
<PerformanceRecordDialog v-model:visible="recordVisible" :row-data="currentRow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-performance-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.my-performance-page__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.my-performance-page__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.my-performance-page__content--team {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.my-performance-page__sidebar {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import type { FormRules } from 'element-plus';
|
||||
import { confirmPerformanceSheet, rejectPerformanceSheet } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'PerformanceActionDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.Performance.Sheet.Sheet | null;
|
||||
actionType: 'confirm' | 'reject';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rowData: null
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
submitted: [];
|
||||
}>();
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const isReject = computed(() => props.actionType === 'reject');
|
||||
const title = computed(() => (isReject.value ? '退回绩效表' : '确认绩效表'));
|
||||
const confirmText = computed(() => (isReject.value ? '确认退回' : '确认'));
|
||||
const rules = computed<FormRules>(() => ({
|
||||
reason: isReject.value ? [createRequiredRule('请输入退回原因')] : []
|
||||
}));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.rowData?.id) return;
|
||||
|
||||
if (isReject.value) {
|
||||
try {
|
||||
await validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
const result = isReject.value
|
||||
? await rejectPerformanceSheet(props.rowData.id, { reason: form.reason.trim() })
|
||||
: await confirmPerformanceSheet(props.rowData.id, { reason: form.reason.trim() || undefined });
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) return;
|
||||
|
||||
window.$message?.success(isReject.value ? '绩效表已退回' : '绩效表已确认');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (!isVisible) return;
|
||||
form.reason = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
append-to-body
|
||||
:confirm-text="confirmText"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<ElDescriptions :column="1" border>
|
||||
<ElDescriptionsItem label="绩效月份">{{ props.rowData?.periodMonth || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="员工">{{ props.rowData?.employeeName || '--' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="实际得分">{{ props.rowData?.actualScoreTotal ?? '--' }}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<ElFormItem class="mt-16px" :label="isReject ? '退回原因' : '确认意见'" prop="reason">
|
||||
<ElInput
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="isReject ? '请输入退回原因' : '可填写确认意见'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,568 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import type { FormRules } from 'element-plus';
|
||||
import '@univerjs/preset-sheets-core/lib/index.css';
|
||||
import {
|
||||
createPerformanceSheet,
|
||||
downloadFile,
|
||||
fetchPerformanceSheet,
|
||||
fetchPerformanceTemplateCurrent,
|
||||
sendPerformanceSheet,
|
||||
updatePerformanceSheetExcel,
|
||||
uploadFile
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { createDefaultPeriodMonth, normalizeScoreText } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceExcelEditorDrawer' });
|
||||
|
||||
interface SubordinateOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.Performance.Sheet.Sheet | null;
|
||||
mode: 'view' | 'edit' | 'create';
|
||||
subordinateOptions?: SubordinateOption[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rowData: null,
|
||||
subordinateOptions: () => []
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [];
|
||||
savedAndSent: [];
|
||||
}>();
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const sending = ref(false);
|
||||
const currentSheet = ref<Api.Performance.Sheet.Sheet | null>(null);
|
||||
const currentTemplate = ref<Api.Performance.Template.Template | null>(null);
|
||||
const errorMessage = ref('');
|
||||
const viewportWidth = ref(typeof window === 'undefined' ? 1920 : window.innerWidth);
|
||||
|
||||
const createForm = reactive({
|
||||
periodMonth: createDefaultPeriodMonth(),
|
||||
employeeId: ''
|
||||
});
|
||||
|
||||
const createFormRules = computed<FormRules>(() => ({
|
||||
periodMonth: [createRequiredRule('请选择绩效月份')],
|
||||
employeeId: [createRequiredRule('请选择员工')]
|
||||
}));
|
||||
|
||||
let univerInstance: any = null;
|
||||
let univerAPI: any = null;
|
||||
let LuckyExcelModule: any = null;
|
||||
let createUniverFn: any = null;
|
||||
let UniverSheetsCorePresetFn: any = null;
|
||||
let univerLocales: Record<string, unknown> | null = null;
|
||||
let excelRuntimeLoading: Promise<void> | null = null;
|
||||
|
||||
const isCreateMode = computed(() => props.mode === 'create');
|
||||
const drawerTitle = computed(() => {
|
||||
let action = '查看';
|
||||
if (isCreateMode.value) action = '新增';
|
||||
else if (props.mode === 'edit') action = '编辑';
|
||||
const selectedEmployee = props.subordinateOptions.find(opt => opt.value === createForm.employeeId);
|
||||
const name = currentSheet.value?.employeeName || props.rowData?.employeeName || selectedEmployee?.label || '';
|
||||
|
||||
return `${action}绩效 Excel${name ? ` - ${name}` : ''}`;
|
||||
});
|
||||
const canSave = computed(() => props.mode !== 'view');
|
||||
const drawerSize = computed(() => (viewportWidth.value >= 2560 ? '60%' : '88%'));
|
||||
|
||||
function syncViewportWidth() {
|
||||
viewportWidth.value = window.innerWidth;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function disposeUniver() {
|
||||
try {
|
||||
univerInstance?.dispose?.();
|
||||
} catch {
|
||||
// ignore dispose errors so closing the drawer still works
|
||||
}
|
||||
|
||||
univerInstance = null;
|
||||
univerAPI = null;
|
||||
|
||||
if (containerRef.value) {
|
||||
containerRef.value.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureExcelRuntime() {
|
||||
if (LuckyExcelModule && createUniverFn && UniverSheetsCorePresetFn && univerLocales) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!excelRuntimeLoading) {
|
||||
excelRuntimeLoading = Promise.all([
|
||||
import('@zwight/luckyexcel'),
|
||||
import('@univerjs/presets'),
|
||||
import('@univerjs/preset-sheets-core'),
|
||||
import('@univerjs/preset-sheets-core/locales/zh-CN'),
|
||||
import('@univerjs/preset-sheets-core/locales/en-US')
|
||||
]).then(([luckyexcelModule, presetsModule, sheetsCoreModule, zhCNLocaleModule, enUSLocaleModule]) => {
|
||||
LuckyExcelModule = luckyexcelModule.default || luckyexcelModule;
|
||||
createUniverFn = presetsModule.createUniver;
|
||||
UniverSheetsCorePresetFn = sheetsCoreModule.UniverSheetsCorePreset;
|
||||
univerLocales = {
|
||||
'zh-CN': zhCNLocaleModule.default || zhCNLocaleModule,
|
||||
'en-US': enUSLocaleModule.default || enUSLocaleModule
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
await excelRuntimeLoading;
|
||||
}
|
||||
|
||||
function transformExcelToUniver(file: File) {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
LuckyExcelModule.transformExcelToUniver(
|
||||
file,
|
||||
(snapshot: any) => resolve(snapshot || {}),
|
||||
(error: unknown) => reject(error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function transformUniverToExcel(snapshot: any, fileName: string) {
|
||||
return new Promise<BlobPart>((resolve, reject) => {
|
||||
LuckyExcelModule.transformUniverToExcel({
|
||||
snapshot,
|
||||
fileName,
|
||||
getBuffer: true,
|
||||
success: (buffer?: unknown) => {
|
||||
if (!buffer) {
|
||||
reject(new Error('Excel 导出结果为空'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(buffer as BlobPart);
|
||||
},
|
||||
error: (error: Error) => reject(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createWorkbook(snapshot: any) {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
disposeUniver();
|
||||
const { univer, univerAPI: api } = createUniverFn({
|
||||
locale: 'zh-CN',
|
||||
locales: univerLocales || undefined,
|
||||
presets: [
|
||||
UniverSheetsCorePresetFn({
|
||||
container: containerRef.value,
|
||||
header: true,
|
||||
toolbar: props.mode !== 'view',
|
||||
formulaBar: props.mode !== 'view'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
univerInstance = univer;
|
||||
univerAPI = api;
|
||||
|
||||
const unitType = api?.Enum?.UniverInstanceType?.UNIVER_SHEET;
|
||||
if (!unitType) {
|
||||
throw new Error('Univer 工作簿初始化失败');
|
||||
}
|
||||
|
||||
// 在 snapshot 数据中预设缩放比例 40%,避免调用不可用的 zoom API
|
||||
const data = snapshot || {};
|
||||
if (data.sheets) {
|
||||
Object.values(data.sheets).forEach((sheet: any) => {
|
||||
if (sheet && typeof sheet === 'object') {
|
||||
sheet.zoomRatio = 0.4;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
univer.createUnit(unitType, data);
|
||||
}
|
||||
|
||||
function getActiveWorkbook() {
|
||||
return univerAPI?.getActiveWorkbook?.();
|
||||
}
|
||||
|
||||
function parseCellAddress(address?: string | null) {
|
||||
const text = String(address || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const matched = text.match(/^([A-Z]+)(\d+)$/u);
|
||||
if (!matched) return null;
|
||||
|
||||
const [, letters, rowText] = matched;
|
||||
let column = 0;
|
||||
for (const letter of letters) {
|
||||
column = column * 26 + letter.charCodeAt(0) - 64;
|
||||
}
|
||||
|
||||
return {
|
||||
row: Number(rowText) - 1,
|
||||
column: column - 1
|
||||
};
|
||||
}
|
||||
|
||||
function readCell(address?: string | null) {
|
||||
const position = parseCellAddress(address);
|
||||
const workbook = getActiveWorkbook();
|
||||
const sheet = workbook?.getActiveSheet?.();
|
||||
if (!position || !sheet) return '';
|
||||
|
||||
try {
|
||||
const range = sheet.getRange(position.row, position.column);
|
||||
const value = range?.getValue?.();
|
||||
|
||||
return normalizeScoreText(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readScores() {
|
||||
const mapping = currentTemplate.value?.scoreCellMapping;
|
||||
|
||||
return {
|
||||
actualScoreTotal: readCell(mapping?.actualScoreTotalCell),
|
||||
baseScoreTotal: readCell(mapping?.baseScoreTotalCell),
|
||||
extraScoreTotal: readCell(mapping?.extraScoreTotalCell)
|
||||
};
|
||||
}
|
||||
|
||||
function getCreateEmployeeName() {
|
||||
return props.subordinateOptions.find(opt => opt.value === createForm.employeeId)?.label || '';
|
||||
}
|
||||
|
||||
function createInitialFileName() {
|
||||
const sheet = currentSheet.value;
|
||||
if (sheet) {
|
||||
return sheet.fileName || `${sheet.periodMonth}-绩效表_${sheet.employeeName}.xlsx`;
|
||||
}
|
||||
|
||||
if (isCreateMode.value && createForm.periodMonth && createForm.employeeId) {
|
||||
return `${createForm.periodMonth}-绩效表_${getCreateEmployeeName()}.xlsx`;
|
||||
}
|
||||
|
||||
return '绩效表.xlsx';
|
||||
}
|
||||
|
||||
async function loadWorkbook() {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
currentSheet.value = null;
|
||||
currentTemplate.value = null;
|
||||
|
||||
try {
|
||||
let sourceFileId = '';
|
||||
|
||||
if (isCreateMode.value) {
|
||||
const templateResult = await fetchPerformanceTemplateCurrent();
|
||||
if (templateResult.error || !templateResult.data) {
|
||||
errorMessage.value = '当前没有可用的绩效模板';
|
||||
return;
|
||||
}
|
||||
|
||||
currentTemplate.value = templateResult.data;
|
||||
sourceFileId = templateResult.data.fileId;
|
||||
} else {
|
||||
if (!props.rowData?.id) return;
|
||||
|
||||
const [sheetResult, templateResult] = await Promise.all([
|
||||
fetchPerformanceSheet(props.rowData.id),
|
||||
fetchPerformanceTemplateCurrent()
|
||||
]);
|
||||
|
||||
if (sheetResult.error || !sheetResult.data) {
|
||||
errorMessage.value = '绩效表详情加载失败';
|
||||
return;
|
||||
}
|
||||
|
||||
currentSheet.value = sheetResult.data;
|
||||
currentTemplate.value = templateResult.error ? null : templateResult.data;
|
||||
sourceFileId = currentSheet.value.fileId || currentTemplate.value?.fileId || '';
|
||||
}
|
||||
|
||||
if (!sourceFileId) {
|
||||
errorMessage.value = '当前绩效表没有可用的 Excel 文件';
|
||||
return;
|
||||
}
|
||||
|
||||
const fileResult = await downloadFile(sourceFileId);
|
||||
if (fileResult.error || !fileResult.data) {
|
||||
errorMessage.value = 'Excel 文件下载失败';
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureExcelRuntime();
|
||||
const file = new File([fileResult.data], createInitialFileName(), {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const snapshot = await transformExcelToUniver(file);
|
||||
await nextTick();
|
||||
createWorkbook(snapshot);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Excel 解析失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCreatedSheet() {
|
||||
if (currentSheet.value) {
|
||||
return currentSheet.value;
|
||||
}
|
||||
|
||||
if (!createForm.periodMonth || !createForm.employeeId) {
|
||||
throw new Error('请先填写绩效月份和员工');
|
||||
}
|
||||
|
||||
const createResult = await createPerformanceSheet({
|
||||
periodMonth: createForm.periodMonth,
|
||||
employeeId: createForm.employeeId
|
||||
});
|
||||
|
||||
if (createResult.error || !createResult.data) {
|
||||
throw new Error('创建绩效记录失败');
|
||||
}
|
||||
|
||||
const sheetResult = await fetchPerformanceSheet(createResult.data);
|
||||
if (sheetResult.error || !sheetResult.data) {
|
||||
throw new Error('绩效记录已创建,但加载详情失败');
|
||||
}
|
||||
|
||||
currentSheet.value = sheetResult.data;
|
||||
return sheetResult.data;
|
||||
}
|
||||
|
||||
async function executeSave(): Promise<Api.Performance.Sheet.Sheet | null> {
|
||||
const workbook = getActiveWorkbook();
|
||||
if (!workbook) return null;
|
||||
|
||||
const scores = readScores();
|
||||
if (!scores.actualScoreTotal || !scores.baseScoreTotal || !scores.extraScoreTotal) {
|
||||
window.$message?.warning('未能读取完整的三种得分总计,请检查模板单元格映射配置');
|
||||
return null;
|
||||
}
|
||||
|
||||
// create 模式先校验表单
|
||||
if (isCreateMode.value) {
|
||||
try {
|
||||
await validate();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
await ensureExcelRuntime();
|
||||
const sheet = await ensureCreatedSheet();
|
||||
const snapshot = workbook.save();
|
||||
const fileName = createInitialFileName();
|
||||
const buffer = await transformUniverToExcel(snapshot, fileName);
|
||||
const file = new File([buffer], fileName, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const uploadResult = await uploadFile(file, `performance/sheets/${sheet.periodMonth}`);
|
||||
|
||||
if (uploadResult.error || !uploadResult.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateResult = await updatePerformanceSheetExcel(sheet.id, {
|
||||
fileId: uploadResult.data.id,
|
||||
fileName,
|
||||
fileVersion: sheet.fileVersion,
|
||||
actualScoreTotal: scores.actualScoreTotal,
|
||||
baseScoreTotal: scores.baseScoreTotal,
|
||||
extraScoreTotal: scores.extraScoreTotal
|
||||
});
|
||||
|
||||
if (updateResult.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sheet;
|
||||
}
|
||||
|
||||
async function handleSaveDraft() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const sheet = await executeSave();
|
||||
if (!sheet) return;
|
||||
|
||||
window.$message?.success(isCreateMode.value ? '绩效表已保存为草稿' : '绩效 Excel 已保存');
|
||||
visible.value = false;
|
||||
emit('saved');
|
||||
} catch (error) {
|
||||
window.$message?.error(error instanceof Error ? error.message : '绩效 Excel 保存失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAndSend() {
|
||||
sending.value = true;
|
||||
try {
|
||||
const sheet = await executeSave();
|
||||
if (!sheet) return;
|
||||
|
||||
const sendResult = await sendPerformanceSheet(sheet.id);
|
||||
if (sendResult.error) return;
|
||||
|
||||
window.$message?.success('绩效表已保存并发送');
|
||||
visible.value = false;
|
||||
emit('savedAndSent');
|
||||
} catch (error) {
|
||||
window.$message?.error(error instanceof Error ? error.message : '绩效表发送失败');
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, async isVisible => {
|
||||
if (!isVisible) {
|
||||
disposeUniver();
|
||||
currentSheet.value = null;
|
||||
currentTemplate.value = null;
|
||||
// 重置创建表单
|
||||
createForm.periodMonth = createDefaultPeriodMonth();
|
||||
createForm.employeeId = '';
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
await loadWorkbook();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncViewportWidth);
|
||||
disposeUniver();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
syncViewportWidth();
|
||||
window.addEventListener('resize', syncViewportWidth);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
v-model="visible"
|
||||
class="performance-excel-editor-drawer"
|
||||
:title="drawerTitle"
|
||||
body-class="performance-excel-editor-drawer__body"
|
||||
:size="drawerSize"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
>
|
||||
<!-- 创建模式下的表单区域 -->
|
||||
<div v-if="isCreateMode" class="performance-excel-editor__create-form">
|
||||
<ElForm ref="formRef" :model="createForm" :rules="createFormRules" label-position="top" inline>
|
||||
<ElFormItem label="绩效月份" prop="periodMonth" class="performance-excel-editor__form-item">
|
||||
<ElDatePicker
|
||||
v-model="createForm.periodMonth"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="选择绩效月份"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="员工" prop="employeeId" class="performance-excel-editor__form-item">
|
||||
<ElSelect v-model="createForm.employeeId" filterable placeholder="选择员工" style="width: 200px">
|
||||
<ElOption v-for="opt in props.subordinateOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="performance-excel-editor">
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
class="performance-excel-editor__alert"
|
||||
type="error"
|
||||
:title="errorMessage"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div ref="containerRef" class="performance-excel-editor__container" />
|
||||
</div>
|
||||
|
||||
<template v-if="canSave" #footer>
|
||||
<div class="performance-excel-editor__footer">
|
||||
<ElButton :loading="saving" :disabled="sending" @click="handleSaveDraft">保存草稿</ElButton>
|
||||
<ElButton type="primary" :loading="sending" :disabled="saving" @click="handleSaveAndSend">发送绩效</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:global(.performance-excel-editor-drawer__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 20px 24px 0;
|
||||
}
|
||||
|
||||
.performance-excel-editor__create-form {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.performance-excel-editor__form-item {
|
||||
margin-bottom: 0;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.performance-excel-editor__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 12px 24px 20px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.performance-excel-editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.performance-excel-editor__alert {
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.performance-excel-editor__container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { fetchPerformanceSheetResponseRecords } from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { formatDateTime, getPerformanceActionLabel } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceRecordDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.Performance.Sheet.Sheet | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rowData: null
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const loading = ref(false);
|
||||
const responseRecords = ref<Api.Performance.Sheet.ResponseRecord[]>([]);
|
||||
|
||||
async function loadData() {
|
||||
if (!props.rowData?.id) return;
|
||||
|
||||
loading.value = true;
|
||||
const { error, data } = await fetchPerformanceSheetResponseRecords(props.rowData.id);
|
||||
responseRecords.value = error || !data ? [] : data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (isVisible) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="员工反馈历史"
|
||||
preset="lg"
|
||||
append-to-body
|
||||
:show-footer="false"
|
||||
:loading="loading"
|
||||
>
|
||||
<ElTable border :data="responseRecords">
|
||||
<ElTableColumn prop="roundNo" label="轮次" width="80" />
|
||||
<ElTableColumn label="动作" width="110">
|
||||
<template #default="{ row }">{{ getPerformanceActionLabel(row.actionType) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="responderName" label="反馈人" width="120" />
|
||||
<ElTableColumn prop="opinion" label="反馈意见" min-width="240" show-overflow-tooltip />
|
||||
<ElTableColumn label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
import TableSearchFields from '@/components/custom/table-search-fields.vue';
|
||||
import { performanceStatusOptions } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceSearch' });
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
subordinateOptions?: Option[];
|
||||
deptOptions?: Option[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
subordinateOptions: () => [],
|
||||
deptOptions: () => []
|
||||
});
|
||||
|
||||
const model = defineModel<Api.Performance.Sheet.SearchParams>('model', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'periodMonthRange',
|
||||
label: '绩效月份',
|
||||
type: 'dateRange',
|
||||
dateRangeType: 'monthrange',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: '选择月份区间'
|
||||
},
|
||||
{ key: 'employeeId', label: '员工', type: 'select', placeholder: '请选择员工', options: props.subordinateOptions },
|
||||
{ key: 'employeeDeptId', label: '部门', type: 'select', placeholder: '请选择部门', options: props.deptOptions },
|
||||
{ key: 'managerName', label: '直属上级', type: 'input', placeholder: '请输入直属上级' },
|
||||
{ key: 'statusCode', label: '状态', type: 'select', placeholder: '请选择状态', options: performanceStatusOptions }
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
|
||||
</template>
|
||||
@@ -0,0 +1,118 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getStatusTagType } from '@/constants/status-tag';
|
||||
|
||||
export interface PerformanceCreateDraft {
|
||||
periodMonth: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
}
|
||||
|
||||
export const PerformancePermission = {
|
||||
TemplateQuery: 'project:performance-template:query',
|
||||
TemplateUpdate: 'project:performance-template:update',
|
||||
SheetQuery: 'project:performance-sheet:query',
|
||||
SheetCreate: 'project:performance-sheet:create',
|
||||
SheetUpdate: 'project:performance-sheet:update',
|
||||
SheetDelete: 'project:performance-sheet:delete',
|
||||
SheetConfirm: 'project:performance-sheet:confirm',
|
||||
SheetReject: 'project:performance-sheet:reject',
|
||||
SheetExport: 'project:performance-sheet:export',
|
||||
TeamDashboard: 'project:performance-sheet:team-dashboard'
|
||||
} as const;
|
||||
|
||||
export const performanceStatusOptions: Array<{
|
||||
label: string;
|
||||
value: Api.Performance.Common.SheetStatusCode;
|
||||
}> = [
|
||||
{ label: '待发送', value: 'draft' },
|
||||
{ label: '待确认', value: 'sent' },
|
||||
{ label: '已确认', value: 'confirmed' },
|
||||
{ label: '已退回', value: 'rejected' }
|
||||
];
|
||||
|
||||
export const performanceActionNameMap: Record<string, string> = {
|
||||
send: '发送',
|
||||
resend: '重新发送',
|
||||
confirm: '确认',
|
||||
reject: '退回',
|
||||
delete: '删除'
|
||||
};
|
||||
|
||||
export function getPerformanceStatusLabel(statusCode?: string | null, statusName?: string | null) {
|
||||
return statusName || performanceStatusOptions.find(item => item.value === statusCode)?.label || statusCode || '--';
|
||||
}
|
||||
|
||||
export function resolvePerformanceStatusTagType(statusCode?: string | null) {
|
||||
return getStatusTagType('performanceSheet', statusCode);
|
||||
}
|
||||
|
||||
export function getPerformanceActionLabel(actionCode?: string | null) {
|
||||
if (!actionCode) return '--';
|
||||
|
||||
return performanceActionNameMap[actionCode] || actionCode;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) return '--';
|
||||
|
||||
const target = dayjs(value);
|
||||
|
||||
return target.isValid() ? target.format('YYYY-MM-DD HH:mm') : value;
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null) {
|
||||
if (!value) return '--';
|
||||
|
||||
const target = dayjs(value);
|
||||
|
||||
return target.isValid() ? target.format('YYYY-MM-DD') : value;
|
||||
}
|
||||
|
||||
export function formatScore(value?: string | number | null) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
|
||||
const numberValue = Number(value);
|
||||
|
||||
return Number.isFinite(numberValue) ? numberValue.toFixed(2) : String(value);
|
||||
}
|
||||
|
||||
export function normalizeScoreText(value: unknown) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
|
||||
const normalized = text.replace(/,/g, '');
|
||||
const numberValue = Number(normalized);
|
||||
|
||||
return Number.isFinite(numberValue) ? numberValue.toFixed(2) : text;
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function getSheetExportName(row: Api.Performance.Sheet.Sheet) {
|
||||
return row.fileName || `${row.periodMonth}月-绩效表_${row.employeeName}.xlsx`;
|
||||
}
|
||||
|
||||
export function createDefaultPeriodMonth() {
|
||||
return dayjs().format('YYYY-MM');
|
||||
}
|
||||
|
||||
export function getDeptOrgTypeLabel(value?: string | null) {
|
||||
const map: Record<string, string> = {
|
||||
direction: '方向',
|
||||
function: '职能',
|
||||
dept: '部门',
|
||||
team: '团队',
|
||||
company: '公司'
|
||||
};
|
||||
|
||||
return map[value || ''] || value || '--';
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { remindTeamPerformance } from '@/service/api';
|
||||
import { formatDateTime, formatScore, getDeptOrgTypeLabel, getPerformanceStatusLabel } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceSummary' });
|
||||
|
||||
interface Props {
|
||||
periodMonthStart: string;
|
||||
periodMonthEnd: string;
|
||||
loading?: boolean;
|
||||
summary?: Api.Performance.Team.Summary | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
summary: null
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
reminded: [];
|
||||
}>();
|
||||
|
||||
const remindingKey = ref('');
|
||||
|
||||
const deptOrgAverageCount = computed(() => props.summary?.deptOrgAverages?.length ?? 0);
|
||||
|
||||
const cards = computed(() => [
|
||||
{ label: '本月绩效表总数', value: props.summary?.totalSheetCount ?? 0 },
|
||||
{ label: '待发送数', value: props.summary?.pendingSendCount ?? 0, key: 'pending_send' as const },
|
||||
{ label: '待确认数', value: props.summary?.pendingConfirmCount ?? 0, key: 'pending_confirm' as const },
|
||||
{ label: '已确认率', value: `${props.summary?.confirmedRate ?? '0.00'}%` },
|
||||
{ label: '各方向绩效平均分', value: deptOrgAverageCount.value, key: 'dept_org_average' as const }
|
||||
]);
|
||||
|
||||
async function handleRemind(type: Api.Performance.Common.RemindType, userIds?: string[]) {
|
||||
const key = userIds?.length === 1 ? `${type}:${userIds[0]}` : `${type}:all`;
|
||||
remindingKey.value = key;
|
||||
|
||||
const { error, data } = await remindTeamPerformance({
|
||||
periodMonthStart: props.periodMonthStart,
|
||||
periodMonthEnd: props.periodMonthEnd,
|
||||
remindType: type,
|
||||
userIds
|
||||
});
|
||||
|
||||
remindingKey.value = '';
|
||||
|
||||
if (error) return;
|
||||
|
||||
window.$message?.success(`已催办 ${data?.remindedCount ?? 0} 人`);
|
||||
emit('reminded');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="props.loading" class="performance-summary">
|
||||
<div class="performance-summary__grid">
|
||||
<div v-for="card in cards" :key="card.label" class="performance-summary__item">
|
||||
<div class="performance-summary__label">{{ card.label }}</div>
|
||||
<div class="performance-summary__value">
|
||||
<template v-if="card.key === 'pending_send'">
|
||||
<ElPopover placement="bottom" :width="360" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="performance-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="performance-summary__popover">
|
||||
<div class="performance-summary__popover-title">待发送人员</div>
|
||||
<div v-if="props.summary?.pendingSendUsers?.length" class="performance-summary__user-list">
|
||||
<div
|
||||
v-for="user in props.summary.pendingSendUsers"
|
||||
:key="`${user.userId}-${user.sheetId || 'none'}`"
|
||||
class="performance-summary__user-item"
|
||||
>
|
||||
<div class="performance-summary__user-main">
|
||||
<span>{{ user.userNickname }}</span>
|
||||
<small>
|
||||
{{ getPerformanceStatusLabel(user.statusCode) }},提醒 {{ user.managerName || '直属上级' }}
|
||||
</small>
|
||||
</div>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:loading="remindingKey === `pending_send:${user.userId}`"
|
||||
@click="handleRemind('pending_send', [user.userId])"
|
||||
>
|
||||
催办
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无待发送人员" />
|
||||
|
||||
<div class="performance-summary__popover-footer">
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="remindingKey === 'pending_send:all'"
|
||||
:disabled="!props.summary?.pendingSendUsers?.length"
|
||||
@click="handleRemind('pending_send')"
|
||||
>
|
||||
一键催办全部
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<template v-else-if="card.key === 'pending_confirm'">
|
||||
<ElPopover placement="bottom" :width="340" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="performance-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="performance-summary__popover">
|
||||
<div class="performance-summary__popover-title">待确认人员</div>
|
||||
<div v-if="props.summary?.pendingConfirmUsers?.length" class="performance-summary__user-list">
|
||||
<div
|
||||
v-for="user in props.summary.pendingConfirmUsers"
|
||||
:key="user.sheetId"
|
||||
class="performance-summary__user-item"
|
||||
>
|
||||
<div class="performance-summary__user-main">
|
||||
<span>{{ user.userNickname }}</span>
|
||||
<small>发送时间:{{ formatDateTime(user.sentTime) }}</small>
|
||||
</div>
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
:loading="remindingKey === `pending_confirm:${user.userId}`"
|
||||
@click="handleRemind('pending_confirm', [user.userId])"
|
||||
>
|
||||
催办
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无待确认人员" />
|
||||
|
||||
<div class="performance-summary__popover-footer">
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="remindingKey === 'pending_confirm:all'"
|
||||
:disabled="!props.summary?.pendingConfirmUsers?.length"
|
||||
@click="handleRemind('pending_confirm')"
|
||||
>
|
||||
一键催办全部
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<template v-else-if="card.key === 'dept_org_average'">
|
||||
<ElPopover placement="bottom" :width="360" trigger="hover">
|
||||
<template #reference>
|
||||
<button type="button" class="performance-summary__link-button">{{ card.value }}</button>
|
||||
</template>
|
||||
|
||||
<div class="performance-summary__popover">
|
||||
<div class="performance-summary__popover-title">各方向绩效平均分</div>
|
||||
<div v-if="props.summary?.deptOrgAverages?.length" class="performance-summary__user-list">
|
||||
<div
|
||||
v-for="item in props.summary.deptOrgAverages"
|
||||
:key="item.deptId"
|
||||
class="performance-summary__user-item performance-summary__user-item--score"
|
||||
>
|
||||
<div class="performance-summary__user-main">
|
||||
<span>{{ item.deptName }}</span>
|
||||
<small>{{ getDeptOrgTypeLabel(item.deptOrgType) }} / {{ item.confirmedCount }} 人</small>
|
||||
</div>
|
||||
<strong class="performance-summary__score">{{ formatScore(item.averageScore) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :image-size="60" description="暂无方向平均分" />
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
{{ card.value }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.performance-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.performance-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);
|
||||
}
|
||||
|
||||
.performance-summary__label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.performance-summary__link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.performance-summary__popover {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.performance-summary__popover-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.performance-summary__user-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.performance-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);
|
||||
}
|
||||
|
||||
.performance-summary__user-item--score {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.performance-summary__user-main {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.performance-summary__user-main span {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.performance-summary__user-main small {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.performance-summary__popover-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.performance-summary__score {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (width <= 1400px) {
|
||||
.performance-summary__grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.performance-summary__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.performance-summary__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,384 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import type { UploadFile, UploadFiles } from 'element-plus';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import {
|
||||
activatePerformanceTemplate,
|
||||
fetchPerformanceTemplatePage,
|
||||
uploadFile,
|
||||
uploadPerformanceTemplate
|
||||
} from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import { formatDateTime } from './performance-shared';
|
||||
|
||||
defineOptions({ name: 'PerformanceTemplateDialog' });
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: [];
|
||||
}>();
|
||||
|
||||
type TemplatePageResponse = Awaited<ReturnType<typeof fetchPerformanceTemplatePage>>;
|
||||
|
||||
const searchParams = reactive<Api.Performance.Template.SearchParams>({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
templateName: undefined,
|
||||
activeFlag: undefined
|
||||
});
|
||||
|
||||
const uploadForm = reactive({
|
||||
templateName: '',
|
||||
remark: '',
|
||||
activeFlag: true,
|
||||
file: null as File | null
|
||||
});
|
||||
|
||||
const uploading = ref(false);
|
||||
const activatingId = ref('');
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
TemplatePageResponse,
|
||||
Api.Performance.Template.Template
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchPerformanceTemplatePage(searchParams),
|
||||
transform: response => {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: searchParams.pageNo ?? 1,
|
||||
pageSize: searchParams.pageSize ?? 10,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
};
|
||||
},
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{ prop: 'templateName', label: '模板名称', minWidth: 170, showOverflowTooltip: true },
|
||||
// { prop: 'fileName', label: '文件名', minWidth: 200, showOverflowTooltip: true },
|
||||
// { prop: 'versionNo', label: '版本', width: 80 },
|
||||
{
|
||||
prop: 'activeFlag',
|
||||
label: '状态',
|
||||
width: 100,
|
||||
formatter: row => <ElTag type={row.activeFlag ? 'success' : 'info'}>{row.activeFlag ? '当前' : '历史'}</ElTag>
|
||||
},
|
||||
{ prop: 'uploadUserName', label: '上传人', width: 110 },
|
||||
{
|
||||
prop: 'uploadTime',
|
||||
label: '上传时间',
|
||||
width: 180,
|
||||
formatter: row => formatDateTime(row.uploadTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getTemplateActions(row)} />
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const selectedFileName = computed(() => uploadForm.file?.name || '');
|
||||
|
||||
function getTemplateActions(row: Api.Performance.Template.Template): BusinessTableAction[] {
|
||||
return [
|
||||
{
|
||||
key: 'activate',
|
||||
label: row.activeFlag ? '已启用' : '启用',
|
||||
buttonType: 'primary',
|
||||
disabled: row.activeFlag || Boolean(activatingId.value),
|
||||
onClick: () => handleActivate(row)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function handleFileChange(file: UploadFile, _files: UploadFiles) {
|
||||
const rawFile = file.raw;
|
||||
|
||||
if (!rawFile) return;
|
||||
|
||||
uploadForm.file = rawFile;
|
||||
if (!uploadForm.templateName) {
|
||||
uploadForm.templateName = rawFile.name.replace(/\.[^.]+$/u, '');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadTemplate() {
|
||||
if (!uploadForm.file) {
|
||||
window.$message?.warning('请选择 Excel 模板文件');
|
||||
return;
|
||||
}
|
||||
if (!uploadForm.templateName.trim()) {
|
||||
window.$message?.warning('请输入模板名称');
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
const fileResult = await uploadFile(uploadForm.file, 'performance/templates');
|
||||
if (fileResult.error || !fileResult.data) {
|
||||
uploading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await uploadPerformanceTemplate({
|
||||
templateName: uploadForm.templateName.trim(),
|
||||
fileId: fileResult.data.id,
|
||||
fileName: uploadForm.file.name,
|
||||
activeFlag: uploadForm.activeFlag,
|
||||
remark: uploadForm.remark.trim() || undefined
|
||||
});
|
||||
uploading.value = false;
|
||||
|
||||
if (result.error) return;
|
||||
|
||||
window.$message?.success('绩效模板已上传');
|
||||
Object.assign(uploadForm, {
|
||||
templateName: '',
|
||||
remark: '',
|
||||
activeFlag: true,
|
||||
file: null
|
||||
});
|
||||
await getDataByPage(1);
|
||||
emit('updated');
|
||||
}
|
||||
|
||||
async function handleActivate(row: Api.Performance.Template.Template) {
|
||||
activatingId.value = row.id;
|
||||
const { error } = await activatePerformanceTemplate(row.id);
|
||||
activatingId.value = '';
|
||||
|
||||
if (error) return;
|
||||
|
||||
window.$message?.success('绩效模板已启用');
|
||||
await getDataByPage(searchParams.pageNo ?? 1);
|
||||
emit('updated');
|
||||
}
|
||||
|
||||
watch(visible, isVisible => {
|
||||
if (isVisible) {
|
||||
getDataByPage(1);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="绩效模板"
|
||||
preset="lg"
|
||||
append-to-body
|
||||
:show-footer="false"
|
||||
max-body-height="76vh"
|
||||
>
|
||||
<div class="performance-template-dialog">
|
||||
<ElCard shadow="never">
|
||||
<ElForm :model="uploadForm" label-position="top" class="performance-template-dialog__upload-form">
|
||||
<div class="performance-template-dialog__upload-grid">
|
||||
<ElFormItem label="模板名称" class="performance-template-dialog__field">
|
||||
<ElInput v-model="uploadForm.templateName" placeholder="请输入模板名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="Excel 文件" class="performance-template-dialog__field">
|
||||
<div class="performance-template-dialog__file-picker">
|
||||
<ElUpload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
:limit="1"
|
||||
:on-change="handleFileChange"
|
||||
>
|
||||
<ElButton plain>
|
||||
<template #icon>
|
||||
<icon-mdi-upload class="text-icon" />
|
||||
</template>
|
||||
选择文件
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
<div class="performance-template-dialog__file-hint">
|
||||
{{ selectedFileName || '支持 .xlsx、.xls,选择后会在这里显示文件名' }}
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
label="上传后启用"
|
||||
class="performance-template-dialog__field performance-template-dialog__switch-field"
|
||||
>
|
||||
<div class="performance-template-dialog__switch-box">
|
||||
<span>上传后立即切换为当前模板</span>
|
||||
<ElSwitch v-model="uploadForm.activeFlag" />
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
label="备注"
|
||||
class="performance-template-dialog__field performance-template-dialog__field--full"
|
||||
>
|
||||
<ElInput v-model="uploadForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</ElFormItem>
|
||||
</div>
|
||||
|
||||
<div class="performance-template-dialog__actions">
|
||||
<ElButton type="primary" :loading="uploading" @click="handleUploadTemplate">上传模板</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<p class="text-16px font-600">模板列表</p>
|
||||
<ElSpace wrap alignment="center">
|
||||
<ElButton @click="getDataByPage()">
|
||||
<template #icon>
|
||||
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
|
||||
</template>
|
||||
刷新
|
||||
</ElButton>
|
||||
<TableColumnSetting v-model:columns="columnChecks" />
|
||||
</ElSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="performance-template-dialog__table">
|
||||
<ElTable v-loading="loading" height="100%" border :data="data">
|
||||
<template v-for="col in columns" :key="String(col.prop)">
|
||||
<ElTableColumn v-bind="col" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-16px 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>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.performance-template-dialog {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__upload-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.performance-template-dialog__file-picker {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__file-hint {
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-field {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-box {
|
||||
height: 100%;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.performance-template-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.performance-template-dialog__table {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.performance-template-dialog__upload-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.performance-template-dialog__upload-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.performance-template-dialog__field--full,
|
||||
.performance-template-dialog__switch-field {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.performance-template-dialog__switch-box {
|
||||
min-height: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -179,7 +179,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
|
||||
</ElTag>
|
||||
)
|
||||
},
|
||||
{ prop: 'approverName', label: '审核人', minWidth: 80, showOverflowTooltip: true },
|
||||
{ prop: 'approverName', label: '审批人', minWidth: 80, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'submitTime',
|
||||
label: '提交时间',
|
||||
@@ -188,7 +188,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
|
||||
},
|
||||
{
|
||||
prop: 'approvalTime',
|
||||
label: '审核时间',
|
||||
label: '审批时间',
|
||||
minWidth: 150,
|
||||
formatter: row => formatOvertimeDateTime(row.approvalTime)
|
||||
},
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, watch } from 'vue';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationActionDialog' });
|
||||
|
||||
type ActionType = 'approve' | 'reject';
|
||||
|
||||
interface Props {
|
||||
actionType: ActionType;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [reason: string | null];
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const model = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
const map: Record<ActionType, string> = {
|
||||
approve: '通过加班申请',
|
||||
reject: '退回加班申请'
|
||||
};
|
||||
|
||||
return map[props.actionType];
|
||||
});
|
||||
|
||||
const reasonLabel = computed(() => {
|
||||
const map: Record<ActionType, string> = {
|
||||
approve: '审核意见',
|
||||
reject: '退回原因'
|
||||
};
|
||||
|
||||
return map[props.actionType];
|
||||
});
|
||||
|
||||
const reasonRequired = computed(() => props.actionType === 'reject');
|
||||
|
||||
const reasonPlaceholder = computed(() => {
|
||||
if (reasonRequired.value) {
|
||||
return `请输入${reasonLabel.value}`;
|
||||
}
|
||||
|
||||
return '可填写审核意见';
|
||||
});
|
||||
|
||||
const rules = computed(() => ({
|
||||
reason: reasonRequired.value
|
||||
? [
|
||||
createRequiredRule(`请输入${reasonLabel.value}`),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error(`请输入${reasonLabel.value}`));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}));
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
emit('submit', model.reason.trim() || null);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (value) {
|
||||
model.reason = '';
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
:confirm-loading="props.loading"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<ElFormItem :label="reasonLabel" prop="reason">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="reasonPlaceholder"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
|
||||
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||
import IconMdiChevronLeft from '~icons/mdi/chevron-left';
|
||||
import IconMdiChevronRight from '~icons/mdi/chevron-right';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationBatchDetailDialog' });
|
||||
|
||||
type ActionType = 'approve' | 'reject';
|
||||
|
||||
interface Props {
|
||||
/** 选中的加班申请 id 列表(原始 id) */
|
||||
selectedIds: string[];
|
||||
@@ -23,8 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: [];
|
||||
reject: [];
|
||||
submit: [payload: { actionType: ActionType; reason: string | null }];
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
@@ -34,6 +34,13 @@ const visible = defineModel<boolean>('visible', {
|
||||
const currentIndex = ref(0);
|
||||
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const approvalModel = reactive({
|
||||
conclusion: 'approve' as ActionType,
|
||||
opinion: ''
|
||||
});
|
||||
|
||||
const currentId = computed(() => props.selectedIds[currentIndex.value] ?? null);
|
||||
|
||||
@@ -74,12 +81,64 @@ function goNext() {
|
||||
loadDetail();
|
||||
}
|
||||
|
||||
const opinionLabel = computed(() => (approvalModel.conclusion === 'reject' ? '退回原因' : '审批意见'));
|
||||
const opinionRequired = computed(() => approvalModel.conclusion === 'reject');
|
||||
const opinionPlaceholder = computed(() => (opinionRequired.value ? `请输入${opinionLabel.value}` : '可填写审批意见'));
|
||||
|
||||
const rules = computed(() => ({
|
||||
opinion: opinionRequired.value
|
||||
? [
|
||||
createRequiredRule(`请输入${opinionLabel.value}`),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error(`请输入${opinionLabel.value}`));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}));
|
||||
|
||||
function resetApprovalForm() {
|
||||
approvalModel.conclusion = 'approve';
|
||||
approvalModel.opinion = '';
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
}
|
||||
|
||||
watch(opinionRequired, async () => {
|
||||
if (!visible.value) return;
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate('opinion');
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
actionType: approvalModel.conclusion,
|
||||
reason: approvalModel.opinion.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
currentIndex.value = 0;
|
||||
loadDetail();
|
||||
resetApprovalForm();
|
||||
} else {
|
||||
detailData.value = null;
|
||||
}
|
||||
@@ -122,27 +181,75 @@ watch(
|
||||
</ElDescriptions>
|
||||
<ElEmpty v-else description="未获取到加班申请详情" />
|
||||
|
||||
<div class="batch-detail__approval-form">
|
||||
<div class="audit-field">
|
||||
<label>审批结论</label>
|
||||
<div class="audit-conclusion">
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{
|
||||
active: approvalModel.conclusion === 'approve',
|
||||
pass: approvalModel.conclusion === 'approve'
|
||||
}"
|
||||
@click="approvalModel.conclusion = 'approve'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M5 8.5L7 10.5L11 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{
|
||||
active: approvalModel.conclusion === 'reject',
|
||||
reject: approvalModel.conclusion === 'reject'
|
||||
}"
|
||||
@click="approvalModel.conclusion = 'reject'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
退回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElForm ref="formRef" :model="approvalModel" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<ElFormItem :label="opinionLabel" prop="opinion">
|
||||
<ElInput
|
||||
v-model="approvalModel.opinion"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="opinionPlaceholder"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="batch-detail__footer">
|
||||
<span class="batch-detail__footer-hint">将对全部 {{ total }} 项统一执行操作</span>
|
||||
<div class="batch-detail__footer-actions">
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton
|
||||
class="batch-detail__approve-btn"
|
||||
type="success"
|
||||
type="primary"
|
||||
:loading="props.actionLoading"
|
||||
:disabled="props.actionLoading || !detailData"
|
||||
@click="emit('approve')"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<template #icon>
|
||||
<IconMdiCheckCircleOutline />
|
||||
</template>
|
||||
通过
|
||||
</ElButton>
|
||||
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
|
||||
<template #icon>
|
||||
<IconMdiCloseCircleOutline />
|
||||
</template>
|
||||
退回
|
||||
确认提交
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,13 +315,61 @@ watch(
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.batch-detail__approve-btn {
|
||||
--el-button-bg-color: #0f766e;
|
||||
--el-button-border-color: #0f766e;
|
||||
--el-button-hover-bg-color: #115e59;
|
||||
--el-button-hover-border-color: #115e59;
|
||||
--el-button-active-bg-color: #134e4a;
|
||||
--el-button-active-border-color: #134e4a;
|
||||
.batch-detail__approval-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.audit-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audit-field label {
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.audit-conclusion {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.conclusion-btn {
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #d8e0e8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475467;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.conclusion-btn:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.pass {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.reject {
|
||||
border-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { formatOvertimeDate, formatOvertimeDateTime } from './overtime-application-shared';
|
||||
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationDetailDialog' });
|
||||
|
||||
type ActionType = 'approve' | 'reject';
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
|
||||
showApprovalActions?: boolean;
|
||||
@@ -20,8 +21,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: [];
|
||||
reject: [];
|
||||
submit: [payload: { actionType: ActionType; reason: string | null }];
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
@@ -30,6 +30,36 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const loading = ref(false);
|
||||
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const approvalModel = reactive({
|
||||
conclusion: 'approve' as ActionType,
|
||||
opinion: ''
|
||||
});
|
||||
|
||||
const opinionLabel = computed(() => (approvalModel.conclusion === 'reject' ? '退回原因' : '审批意见'));
|
||||
const opinionRequired = computed(() => approvalModel.conclusion === 'reject');
|
||||
const opinionPlaceholder = computed(() => (opinionRequired.value ? `请输入${opinionLabel.value}` : '可填写审批意见'));
|
||||
|
||||
const rules = computed(() => ({
|
||||
opinion: opinionRequired.value
|
||||
? [
|
||||
createRequiredRule(`请输入${opinionLabel.value}`),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error(`请输入${opinionLabel.value}`));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}));
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.rowData?.id) {
|
||||
@@ -44,24 +74,47 @@ async function loadDetail() {
|
||||
detailData.value = error || !data ? props.rowData : data;
|
||||
}
|
||||
|
||||
function resetApprovalForm() {
|
||||
approvalModel.conclusion = 'approve';
|
||||
approvalModel.opinion = '';
|
||||
nextTick(() => {
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
}
|
||||
|
||||
watch(opinionRequired, async () => {
|
||||
if (!visible.value || !props.showApprovalActions) return;
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate('opinion');
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
actionType: approvalModel.conclusion,
|
||||
reason: approvalModel.opinion.trim() || null
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
loadDetail();
|
||||
resetApprovalForm();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="加班申请详情"
|
||||
preset="md"
|
||||
:loading="loading"
|
||||
:show-footer="props.showApprovalActions"
|
||||
>
|
||||
<BusinessFormDialog v-model="visible" title="加班申请详情" preset="md" :loading="loading">
|
||||
<ElDescriptions v-if="detailData" class="overtime-application-detail-dialog__descriptions" :column="2" border>
|
||||
<ElDescriptionsItem label="申请人" label-class-name="overtime-application-detail-dialog__label">
|
||||
{{ detailData.applicantName }}
|
||||
@@ -84,25 +137,74 @@ watch(
|
||||
</ElDescriptions>
|
||||
<ElEmpty v-else description="未获取到加班申请详情" />
|
||||
|
||||
<div v-if="props.showApprovalActions" class="overtime-application-detail-dialog__approval-form">
|
||||
<div class="audit-field">
|
||||
<label>审批结论</label>
|
||||
<div class="audit-conclusion">
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{
|
||||
active: approvalModel.conclusion === 'approve',
|
||||
pass: approvalModel.conclusion === 'approve'
|
||||
}"
|
||||
@click="approvalModel.conclusion = 'approve'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M5 8.5L7 10.5L11 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="conclusion-btn"
|
||||
:class="{
|
||||
active: approvalModel.conclusion === 'reject',
|
||||
reject: approvalModel.conclusion === 'reject'
|
||||
}"
|
||||
@click="approvalModel.conclusion = 'reject'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M6 6L10 10M10 6L6 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
退回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElForm ref="formRef" :model="approvalModel" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<ElFormItem :label="opinionLabel" prop="opinion">
|
||||
<ElInput
|
||||
v-model="approvalModel.opinion"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="opinionPlaceholder"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="overtime-application-detail-dialog__footer">
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton
|
||||
class="overtime-application-detail-dialog__approve-btn"
|
||||
type="success"
|
||||
v-if="props.showApprovalActions"
|
||||
type="primary"
|
||||
:loading="props.actionLoading"
|
||||
:disabled="props.actionLoading || !detailData"
|
||||
@click="emit('approve')"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<template #icon>
|
||||
<IconMdiCheckCircleOutline />
|
||||
</template>
|
||||
通过
|
||||
</ElButton>
|
||||
<ElButton type="danger" plain :disabled="props.actionLoading || !detailData" @click="emit('reject')">
|
||||
<template #icon>
|
||||
<IconMdiCloseCircleOutline />
|
||||
</template>
|
||||
退回
|
||||
确认提交
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -116,13 +218,61 @@ watch(
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overtime-application-detail-dialog__approve-btn {
|
||||
--el-button-bg-color: #0f766e;
|
||||
--el-button-border-color: #0f766e;
|
||||
--el-button-hover-bg-color: #115e59;
|
||||
--el-button-hover-border-color: #115e59;
|
||||
--el-button-active-bg-color: #134e4a;
|
||||
--el-button-active-border-color: #134e4a;
|
||||
.overtime-application-detail-dialog__approval-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.audit-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audit-field label {
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.audit-conclusion {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.conclusion-btn {
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #d8e0e8;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475467;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.conclusion-btn:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.pass {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.reject {
|
||||
border-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
:deep(.overtime-application-detail-dialog__descriptions .el-descriptions__cell) {
|
||||
|
||||
@@ -80,7 +80,7 @@ const rules = computed(
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
approverId: [createRequiredRule('请选择审核人')]
|
||||
approverId: [createRequiredRule('请选择审批人')]
|
||||
}) satisfies Record<keyof Api.OvertimeApplication.SaveOvertimeApplicationParams, App.Global.FormRule[]>
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ watch(
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="审核人" prop="approverId">
|
||||
<ElFormItem label="审批人" prop="approverId">
|
||||
<ElInput
|
||||
class="overtime-application-operate-dialog__readonly-input"
|
||||
:model-value="approverName"
|
||||
|
||||
@@ -95,9 +95,9 @@ const fields = computed<SearchField[]>(() => [
|
||||
},
|
||||
{
|
||||
key: 'approverName',
|
||||
label: '审核人',
|
||||
label: '审批人',
|
||||
type: 'input',
|
||||
placeholder: '请输入审核人'
|
||||
placeholder: '请输入审批人'
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
@@ -123,12 +123,8 @@ const table = useUIPaginatedTable<
|
||||
]
|
||||
});
|
||||
|
||||
const summaryPeriod = computed(() =>
|
||||
resolveWorkReportSummaryPeriod('monthly', {
|
||||
currentRow: table.data.value[0],
|
||||
periodRange: searchParams.periodStartDate
|
||||
})
|
||||
);
|
||||
// 团队统计始终使用当前周期(本月),不跟随列表第一条数据的周期
|
||||
const summaryPeriod = computed(() => resolveWorkReportSummaryPeriod('monthly'));
|
||||
|
||||
function getRowActions(row: Api.WorkReport.Monthly.MonthlyReport): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
|
||||
@@ -801,7 +801,7 @@ watch(
|
||||
|
||||
<div class="form-actions approval-form-actions">
|
||||
<ElButton @click="emit('back')">退出审批</ElButton>
|
||||
<ElButton type="primary" class="btn-submit" @click="openAuditDialog">开始审批</ElButton>
|
||||
<ElButton type="primary" @click="openAuditDialog">开始审批</ElButton>
|
||||
</div>
|
||||
|
||||
<BusinessFormDialog
|
||||
@@ -915,8 +915,8 @@ watch(
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -1009,12 +1009,12 @@ watch(
|
||||
}
|
||||
|
||||
.radio-group-full :deep(.el-radio.is-checked) {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.review-grid,
|
||||
@@ -1123,7 +1123,7 @@ watch(
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
@@ -1292,7 +1292,7 @@ watch(
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: normal;
|
||||
@@ -1308,10 +1308,10 @@ watch(
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-section-tasks) {
|
||||
.rich-editor :deep(.rich-section-task) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-left: 14px;
|
||||
@@ -1324,7 +1324,7 @@ watch(
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-category-line) {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
@@ -1454,7 +1454,7 @@ watch(
|
||||
.structured-section-title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1467,7 +1467,7 @@ watch(
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.structured-section-tasks {
|
||||
@@ -1551,14 +1551,14 @@ watch(
|
||||
}
|
||||
|
||||
.conclusion-btn:hover {
|
||||
border-color: #0f766e;
|
||||
color: #0f766e;
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.pass {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.reject {
|
||||
@@ -1689,9 +1689,9 @@ watch(
|
||||
}
|
||||
|
||||
.feedback-tag.success {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #d1fae5;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
}
|
||||
|
||||
.feedback-tag.warning {
|
||||
@@ -1838,16 +1838,6 @@ watch(
|
||||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #0f766e !important;
|
||||
border-color: #0f766e !important;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #0d9488 !important;
|
||||
border-color: #0d9488 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.form-head,
|
||||
.compose-grid,
|
||||
|
||||
@@ -1077,7 +1077,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
class="btn-submit"
|
||||
:disabled="
|
||||
!planForm.workItem.trim() ||
|
||||
!planForm.sections.some(section => section.category.trim() && section.tasks.length)
|
||||
@@ -1094,7 +1093,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
<div v-if="!isReadonly" class="form-actions">
|
||||
<!-- <ElButton>重置表单</ElButton>-->
|
||||
<ElButton @click="emit('save')">保存草稿</ElButton>
|
||||
<ElButton type="primary" class="btn-submit" @click="emit('submit')">提交审批</ElButton>
|
||||
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
|
||||
</div>
|
||||
|
||||
<BusinessFormDialog
|
||||
@@ -1327,8 +1326,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -1421,12 +1420,12 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.radio-group-full :deep(.el-radio.is-checked) {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.review-grid,
|
||||
@@ -1546,7 +1545,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
@@ -1744,8 +1743,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.rich-editor:focus {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.rich-editor:empty::before {
|
||||
@@ -1769,7 +1768,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: normal;
|
||||
@@ -1785,10 +1784,10 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-section-tasks) {
|
||||
.rich-editor :deep(.rich-section-task) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-left: 14px;
|
||||
@@ -1828,7 +1827,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-category-line) {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
@@ -1899,7 +1898,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
.structured-section-title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1912,7 +1911,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.structured-task {
|
||||
@@ -1928,7 +1927,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
.structured-task-category {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1941,7 +1940,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.structured-task-title {
|
||||
@@ -1988,9 +1987,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.plan-section.active {
|
||||
border-color: #cfe3e0;
|
||||
background: #f7fbfa;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.06);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: inset 0 0 0 1px var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.plan-section-head {
|
||||
@@ -2124,7 +2123,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.inline-plan-card {
|
||||
border-color: rgba(15, 118, 110, 0.42);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: #f8fbfc;
|
||||
}
|
||||
|
||||
@@ -2171,14 +2170,14 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
.inline-plan-card :deep(.el-input__wrapper.is-focus),
|
||||
.inline-plan-card :deep(.el-select__wrapper.is-focused) {
|
||||
box-shadow:
|
||||
0 0 0 1px #0f766e inset,
|
||||
0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
0 0 0 1px var(--el-color-primary) inset,
|
||||
0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.inline-plan-card :deep(.el-textarea__inner:focus) {
|
||||
box-shadow:
|
||||
0 0 0 1px #0f766e inset,
|
||||
0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
0 0 0 1px var(--el-color-primary) inset,
|
||||
0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.inline-plan-card .inline-task-row :deep(.el-select__selected-item),
|
||||
@@ -2216,8 +2215,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
.inline-plan-card .inline-task-row :deep(.el-input-number:focus-within .el-input__wrapper) {
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 0 1px #0f766e inset,
|
||||
0 0 0 2px rgba(15, 118, 110, 0.1) !important;
|
||||
0 0 0 1px var(--el-color-primary) inset,
|
||||
0 0 0 2px var(--el-color-primary-light-8) !important;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@@ -2237,16 +2236,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #0f766e !important;
|
||||
border-color: #0f766e !important;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #0d9488 !important;
|
||||
border-color: #0d9488 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.form-head,
|
||||
.compose-grid,
|
||||
|
||||
@@ -128,13 +128,8 @@ const table = useUIPaginatedTable<
|
||||
]
|
||||
});
|
||||
|
||||
const summaryPeriod = computed(() =>
|
||||
resolveWorkReportSummaryPeriod('project', {
|
||||
currentRow: table.data.value[0],
|
||||
periodRange: searchParams.periodStartDate,
|
||||
flag: searchParams.flag
|
||||
})
|
||||
);
|
||||
// 团队统计始终使用当前周期(当前半月),不跟随列表第一条数据的周期
|
||||
const summaryPeriod = computed(() => resolveWorkReportSummaryPeriod('project'));
|
||||
|
||||
function getRowActions(row: Api.WorkReport.Project.ProjectReport): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
|
||||
@@ -474,7 +474,6 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
<ElButton
|
||||
size="small"
|
||||
type="primary"
|
||||
class="btn-submit"
|
||||
:disabled="!planForm.title.trim() || isDuplicatePlanTitle"
|
||||
@click="submitInlinePlan"
|
||||
>
|
||||
@@ -520,11 +519,11 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
<div v-if="!isReadonly" class="form-actions">
|
||||
<!-- <ElButton>重置表单</ElButton>-->
|
||||
<ElButton @click="emit('save')">保存草稿</ElButton>
|
||||
<ElButton type="primary" class="btn-submit" @click="emit('submit')">提交审批</ElButton>
|
||||
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
|
||||
</div>
|
||||
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
|
||||
<ElButton @click="emit('back')">退出审批</ElButton>
|
||||
<ElButton type="primary" class="btn-submit" @click="emit('requestApprove')">开始审批</ElButton>
|
||||
<ElButton type="primary" @click="emit('requestApprove')">开始审批</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -567,8 +566,8 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -671,8 +670,8 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
}
|
||||
|
||||
.member-chip.more {
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -715,7 +714,7 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
}
|
||||
|
||||
.compact-work-card:hover {
|
||||
border-color: rgba(15, 118, 110, 0.45);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -742,7 +741,7 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
@@ -806,7 +805,7 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
}
|
||||
|
||||
.work-title-input:focus {
|
||||
border-bottom-color: #0f766e;
|
||||
border-bottom-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.work-title-line span {
|
||||
@@ -908,8 +907,8 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
|
||||
.rich-editor:focus {
|
||||
box-shadow:
|
||||
0 0 0 1px #0f766e inset,
|
||||
0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
0 0 0 1px var(--el-color-primary) inset,
|
||||
0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.rich-editor:empty::before {
|
||||
@@ -938,7 +937,7 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
}
|
||||
|
||||
.inline-plan-card {
|
||||
border-color: rgba(15, 118, 110, 0.42);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: #f8fbfc;
|
||||
}
|
||||
|
||||
@@ -1001,8 +1000,8 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
.inline-plan-card :deep(.el-input__wrapper.is-focus),
|
||||
.inline-plan-card :deep(.el-select__wrapper.is-focused) {
|
||||
box-shadow:
|
||||
0 0 0 1px #0f766e inset,
|
||||
0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
0 0 0 1px var(--el-color-primary) inset,
|
||||
0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@@ -1034,16 +1033,6 @@ function notifyTitleSaved(item: WorkItem) {
|
||||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #0f766e !important;
|
||||
border-color: #0f766e !important;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #0d9488 !important;
|
||||
border-color: #0d9488 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.compose-grid,
|
||||
.review-grid,
|
||||
|
||||
@@ -385,14 +385,14 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
.conclusion-btn:hover {
|
||||
border-color: #0f766e;
|
||||
color: #0f766e;
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.pass {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.conclusion-btn.active.reject {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { Calendar } from '@element-plus/icons-vue';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {
|
||||
type WorkReportPeriodOption,
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
} from '../utils';
|
||||
import { WORK_REPORT_TYPE_LABEL, type WorkReportType } from '../types';
|
||||
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
defineOptions({ name: 'WorkReportCreateDialog' });
|
||||
|
||||
interface Props {
|
||||
@@ -44,11 +48,24 @@ const emit = defineEmits<{
|
||||
|
||||
const selectedPeriodKey = ref('');
|
||||
const selectedProjectId = ref('');
|
||||
const customWeekDate = ref('');
|
||||
const rawCustomWeekDate = ref('');
|
||||
const customMonth = ref('');
|
||||
const customProjectMonth = ref('');
|
||||
const customProjectFlag = ref(1);
|
||||
|
||||
// 自定义周报周期:无论用户点哪一天,都归一到该 ISO 周的周一,
|
||||
// 这样 ElDatePicker 使用内置 dayjs 的 ww(locale week)也能正确显示 ISO 周数。
|
||||
const customWeekDate = computed<string>({
|
||||
get: () => rawCustomWeekDate.value,
|
||||
set: val => {
|
||||
if (!val) {
|
||||
rawCustomWeekDate.value = val;
|
||||
return;
|
||||
}
|
||||
rawCustomWeekDate.value = dayjs(val).startOf('isoWeek').format('YYYY-MM-DD');
|
||||
}
|
||||
});
|
||||
|
||||
const selectedReportType = computed<WorkReportType>(() => {
|
||||
if (props.defaultReportType === 'project' && !props.projectVisible) return 'weekly';
|
||||
return props.defaultReportType;
|
||||
@@ -157,6 +174,8 @@ function handleConfirm() {
|
||||
:close-on-click-modal="false"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<!-- 必须用单根节点包裹:ElScrollbar 默认 slot 接收多根 Fragment 时,destroy-on-close 后 patch 会触发 null __vnode 错误 -->
|
||||
<div class="work-report-create-dialog__body">
|
||||
<div v-if="selectedReportType === 'project'" class="work-report-create-dialog__project-select">
|
||||
<label class="work-report-create-dialog__label">项目</label>
|
||||
<ElSelect v-model="selectedProjectId" class="w-full" placeholder="请选择项目" filterable>
|
||||
@@ -265,6 +284,7 @@ function handleConfirm() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer="{ close }">
|
||||
<div class="work-report-create-dialog__footer">
|
||||
@@ -320,13 +340,13 @@ function handleConfirm() {
|
||||
}
|
||||
|
||||
.work-report-create-dialog__choice:hover {
|
||||
border-color: rgba(15, 118, 110, 0.28);
|
||||
border-color: var(--el-color-primary-light-3);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.work-report-create-dialog__choice.is-active {
|
||||
border-color: #0f766e;
|
||||
background: #ecfdf5;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.work-report-create-dialog__choice-title {
|
||||
@@ -381,9 +401,9 @@ function handleConfirm() {
|
||||
.work-report-create-dialog__custom-period {
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(15, 118, 110, 0.18);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #f8fffd 0%, #ffffff 100%);
|
||||
background: linear-gradient(180deg, var(--el-color-primary-light-9) 0%, #ffffff 100%);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
@@ -422,7 +442,7 @@ function handleConfirm() {
|
||||
}
|
||||
|
||||
.work-report-create-dialog__custom-project-item:hover {
|
||||
border-color: rgba(15, 118, 110, 0.4);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.work-report-create-dialog__custom-project-item-label {
|
||||
@@ -460,10 +480,10 @@ function handleConfirm() {
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(15, 118, 110, 0.18);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 999px;
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
@@ -472,7 +492,7 @@ function handleConfirm() {
|
||||
|
||||
.work-report-create-dialog__period-preview-icon {
|
||||
font-size: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.work-report-create-dialog__period-preview-text {
|
||||
@@ -481,7 +501,7 @@ function handleConfirm() {
|
||||
}
|
||||
|
||||
.work-report-create-dialog__period-preview-value {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -529,6 +549,6 @@ function handleConfirm() {
|
||||
|
||||
:global(.work-report-create-date-popper .el-date-table td.current:not(.disabled) .el-date-table-cell__text),
|
||||
:global(.work-report-create-date-popper .el-month-table td.current:not(.disabled) .cell) {
|
||||
background-color: #0f766e;
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
fetchInitMonthlyReport,
|
||||
fetchInitProjectReport,
|
||||
fetchInitWeeklyReport,
|
||||
fetchPerformanceMonthlyResult,
|
||||
fetchPreviewMonthlyReportDefaultDraft,
|
||||
fetchPreviewProjectReportDefaultDraft,
|
||||
fetchPreviewWeeklyReportDefaultDraft,
|
||||
@@ -111,6 +112,7 @@ const monthlyApprovalDraft = reactive<Api.WorkReport.Monthly.MonthlyReportApprov
|
||||
supervisorSignName: '',
|
||||
supervisorSignedDate: ''
|
||||
});
|
||||
const monthlyPerformanceAutoFilled = ref(false);
|
||||
|
||||
const weeklyModel = reactive<Api.WorkReport.Weekly.WeeklyReportSaveParams>(createWeeklySaveParams());
|
||||
const monthlyModel = reactive<Api.WorkReport.Monthly.MonthlyReportSaveParams>(createMonthlySaveParams());
|
||||
@@ -167,6 +169,7 @@ function resetModels() {
|
||||
supervisorSignName: '',
|
||||
supervisorSignedDate: ''
|
||||
});
|
||||
monthlyPerformanceAutoFilled.value = false;
|
||||
}
|
||||
|
||||
function patchMonthlyApprovalDefaults(report?: Partial<Api.WorkReport.Monthly.MonthlyReport> | null) {
|
||||
@@ -471,9 +474,19 @@ watch(visible, async isVisible => {
|
||||
|
||||
if (props.rowData?.id) {
|
||||
await loadDetail(props.rowData.id);
|
||||
await fillMonthlyPerformanceResult();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => currentStage.value,
|
||||
stage => {
|
||||
if (stage === 'approval') {
|
||||
fillMonthlyPerformanceResult();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function hasTextValue(value: unknown) {
|
||||
const text = String(value ?? '')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
@@ -706,6 +719,45 @@ function handleBack() {
|
||||
|
||||
function handleViewApproval() {
|
||||
currentStage.value = 'approval';
|
||||
fillMonthlyPerformanceResult();
|
||||
}
|
||||
|
||||
function resolveMonthlyPeriodMonth() {
|
||||
const startDate = monthlyModel.periodStartDate || baseInfo.value?.periodStartDate;
|
||||
const fromStartDate = dayjs(startDate);
|
||||
if (fromStartDate.isValid()) {
|
||||
return fromStartDate.format('YYYY-MM');
|
||||
}
|
||||
|
||||
const periodKey = monthlyModel.periodKey || baseInfo.value?.periodKey;
|
||||
const periodKeyMatch = String(periodKey || '').match(/(\d{4})[-/年]?(\d{1,2})/u);
|
||||
if (periodKeyMatch) {
|
||||
return `${periodKeyMatch[1]}-${periodKeyMatch[2].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const periodLabel = monthlyModel.periodLabel || baseInfo.value?.periodLabel;
|
||||
const periodLabelMatch = String(periodLabel || '').match(/(\d{4})[-/年]?(\d{1,2})/u);
|
||||
if (periodLabelMatch) {
|
||||
return `${periodLabelMatch[1]}-${periodLabelMatch[2].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function fillMonthlyPerformanceResult() {
|
||||
if (props.reportType !== 'monthly' || monthlyPerformanceAutoFilled.value) return;
|
||||
if (monthlyApprovalDraft.performanceResult?.trim()) return;
|
||||
|
||||
const employeeId = (baseInfo.value as Api.WorkReport.Monthly.MonthlyReport | null)?.reporterId;
|
||||
const periodMonth = resolveMonthlyPeriodMonth();
|
||||
if (!employeeId || !periodMonth) return;
|
||||
|
||||
monthlyPerformanceAutoFilled.value = true;
|
||||
const { error, data } = await fetchPerformanceMonthlyResult(employeeId, periodMonth);
|
||||
if (error || !data?.actualScoreTotal) return;
|
||||
if (monthlyApprovalDraft.performanceResult?.trim()) return;
|
||||
|
||||
monthlyApprovalDraft.performanceResult = String(data.actualScoreTotal);
|
||||
}
|
||||
|
||||
function handleRequestApprove() {
|
||||
|
||||
@@ -38,10 +38,12 @@ export function formatPeriodDisplayLabel(label?: string | null) {
|
||||
.replace(/\s*(周报|月报|项目半月报|半月报)\s*$/u, '');
|
||||
}
|
||||
|
||||
// 使用 ISO 周数,确保与 buildWeeklyPeriodFromDate 的计算一致
|
||||
export function getIsoWeekDisplay(date: string | dayjs.Dayjs) {
|
||||
const selectedDate = dayjs(date);
|
||||
if (!selectedDate.isValid()) return '';
|
||||
return `${selectedDate.format('YYYY')} 第${String(selectedDate.isoWeek()).padStart(2, '0')} 周`;
|
||||
// isoWeek() 返回 ISO 8601 周数(周一为一周起始)
|
||||
return `${selectedDate.format('GGGG')} 第${String(selectedDate.isoWeek()).padStart(2, '0')} 周`;
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line max-params */
|
||||
|
||||
@@ -152,12 +152,8 @@ const table = useUIPaginatedTable<
|
||||
]
|
||||
});
|
||||
|
||||
const summaryPeriod = computed(() =>
|
||||
resolveWorkReportSummaryPeriod('weekly', {
|
||||
currentRow: table.data.value[0],
|
||||
periodRange: searchParams.periodStartDate
|
||||
})
|
||||
);
|
||||
// 团队统计始终使用当前周期(本周),不跟随列表第一条数据的周期
|
||||
const summaryPeriod = computed(() => resolveWorkReportSummaryPeriod('weekly'));
|
||||
|
||||
function getRowActions(row: Api.WorkReport.Weekly.WeeklyReport): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
|
||||
@@ -1386,11 +1386,11 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
<div v-if="!isReadonly" class="form-actions">
|
||||
<!-- <ElButton>重置表单</ElButton>-->
|
||||
<ElButton @click="emit('save')">保存草稿</ElButton>
|
||||
<ElButton type="primary" class="btn-submit" @click="emit('submit')">提交审批</ElButton>
|
||||
<ElButton type="primary" @click="emit('submit')">提交审批</ElButton>
|
||||
</div>
|
||||
<div v-else-if="scene === 'approval'" class="form-actions approval-form-actions">
|
||||
<ElButton @click="emit('back')">退出审批</ElButton>
|
||||
<ElButton type="primary" class="btn-submit" @click="emit('requestApprove')">开始审批</ElButton>
|
||||
<ElButton type="primary" @click="emit('requestApprove')">开始审批</ElButton>
|
||||
</div>
|
||||
|
||||
<BusinessFormDialog
|
||||
@@ -1672,8 +1672,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -1766,12 +1766,12 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.radio-group-full :deep(.el-radio.is-checked) {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.radio-group-full :deep(.el-radio__input.is-checked + .el-radio__label) {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.review-grid,
|
||||
@@ -1891,7 +1891,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
@@ -2091,8 +2091,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.rich-editor:focus {
|
||||
border-color: #0f766e;
|
||||
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.1);
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.rich-editor:empty::before {
|
||||
@@ -2113,7 +2113,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-category-line) {
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
@@ -2153,7 +2153,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.rich-editor--preview:hover {
|
||||
border-color: #0f766e;
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.structured-preview__popover {
|
||||
@@ -2193,7 +2193,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: normal;
|
||||
@@ -2239,7 +2239,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.rich-editor :deep(.rich-task-detail) {
|
||||
@@ -2300,7 +2300,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
.structured-task-title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: #0f766e;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -2313,7 +2313,7 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.structured-task-detail {
|
||||
@@ -2330,8 +2330,8 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
@@ -2401,9 +2401,9 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
}
|
||||
|
||||
.plan-section.active {
|
||||
border-color: #cfe3e0;
|
||||
background: #f7fbfa;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.06);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: inset 0 0 0 1px var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.plan-section-head {
|
||||
@@ -2627,16 +2627,6 @@ function syncRichSupport(item: PlanItem, event: Event) {
|
||||
box-shadow: 0 -8px 18px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #0f766e !important;
|
||||
border-color: #0f766e !important;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #0d9488 !important;
|
||||
border-color: #0d9488 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.form-head,
|
||||
.compose-grid,
|
||||
|
||||
@@ -29,7 +29,6 @@ import TaskWorklogDialog from '@/views/project/project/execution/modules/task-wo
|
||||
import PersonalItemDetailDialog from '@/views/personal-center/my-item/modules/personal-item-detail-dialog.vue';
|
||||
import PersonalItemOperateDialog from '@/views/personal-center/my-item/modules/personal-item-operate-dialog.vue';
|
||||
import PersonalItemStatusActionDialog from '@/views/personal-center/my-item/modules/personal-item-status-action-dialog.vue';
|
||||
import OvertimeApplicationActionDialog from '@/views/personal-center/overtime-application/modules/overtime-application-action-dialog.vue';
|
||||
import OvertimeApplicationBatchDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-batch-detail-dialog.vue';
|
||||
import OvertimeApplicationDetailDialog from '@/views/personal-center/overtime-application/modules/overtime-application-detail-dialog.vue';
|
||||
import WorkReportPrototypePageDialog from '@/views/personal-center/work-report/shared/components/prototype-page-dialog.vue';
|
||||
@@ -169,12 +168,9 @@ const taskStatusAction = ref<Api.Project.LifecycleAction<Api.Project.ProjectTask
|
||||
const taskWorklogVisible = ref(false);
|
||||
const taskWorklogTask = ref<Api.Project.ProjectTask | null>(null);
|
||||
const overtimeDetailVisible = ref(false);
|
||||
const overtimeActionVisible = ref(false);
|
||||
const overtimeActionSubmitting = ref(false);
|
||||
const currentOvertimeApplication = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
const currentOvertimeActionType = ref<OvertimeApprovalActionType>('approve');
|
||||
const batchDetailVisible = ref(false);
|
||||
const batchActionVisible = ref(false);
|
||||
const batchSubmitting = ref(false);
|
||||
const workReportDetailVisible = ref(false);
|
||||
const currentWorkReport = ref<WorkReportRow | null>(null);
|
||||
@@ -183,9 +179,6 @@ const currentWorkReportType = ref<WorkReportType>('weekly');
|
||||
// 批量审批选中状态(存原始加班申请 id,避免映射转换)
|
||||
const selectedOvertimeIds = ref<Set<string>>(new Set());
|
||||
|
||||
// 批量审批是否为当前操作来源(区分单条审批和批量审批的 submit 回调)
|
||||
const isBatchActionMode = ref(false);
|
||||
|
||||
const OVERTIME_APPROVAL_ACTION_ICONS = {
|
||||
detail: markRaw(IconMdiEyeOutline)
|
||||
};
|
||||
@@ -569,13 +562,6 @@ function openOvertimeDetail(item: WorkbenchTodoItem) {
|
||||
overtimeDetailVisible.value = true;
|
||||
}
|
||||
|
||||
function openCurrentOvertimeAction(actionType: OvertimeApprovalActionType) {
|
||||
if (!currentOvertimeApplication.value) return;
|
||||
|
||||
currentOvertimeActionType.value = actionType;
|
||||
overtimeActionVisible.value = true;
|
||||
}
|
||||
|
||||
function findWorkReportApprovalRow(item: WorkbenchTodoItem) {
|
||||
if (!item.approvalBizId || !isWorkReportApprovalBizType(item.approvalBizType)) {
|
||||
return null;
|
||||
@@ -601,25 +587,24 @@ function openWorkReportDetail(item: WorkbenchTodoItem) {
|
||||
workReportDetailVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleOvertimeActionSubmit(reason: string | null) {
|
||||
async function handleOvertimeActionSubmit(payload: { actionType: OvertimeApprovalActionType; reason: string | null }) {
|
||||
if (!currentOvertimeApplication.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
overtimeActionSubmitting.value = true;
|
||||
const result =
|
||||
currentOvertimeActionType.value === 'approve'
|
||||
? await fetchApproveOvertimeApplication(currentOvertimeApplication.value.id, { reason })
|
||||
: await fetchRejectOvertimeApplication(currentOvertimeApplication.value.id, { reason });
|
||||
payload.actionType === 'approve'
|
||||
? await fetchApproveOvertimeApplication(currentOvertimeApplication.value.id, { reason: payload.reason })
|
||||
: await fetchRejectOvertimeApplication(currentOvertimeApplication.value.id, { reason: payload.reason });
|
||||
overtimeActionSubmitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
overtimeActionVisible.value = false;
|
||||
overtimeDetailVisible.value = false;
|
||||
window.$message?.success(currentOvertimeActionType.value === 'approve' ? '加班申请已通过' : '加班申请已退回');
|
||||
window.$message?.success(payload.actionType === 'approve' ? '加班申请已通过' : '加班申请已退回');
|
||||
await loadOvertimeApprovalItems();
|
||||
}
|
||||
|
||||
@@ -748,30 +733,20 @@ function handleBatchReview() {
|
||||
batchDetailVisible.value = true;
|
||||
}
|
||||
|
||||
// 批量详情弹窗中点击"通过"或"退回"
|
||||
function openBatchActionDialog(actionType: OvertimeApprovalActionType) {
|
||||
currentOvertimeActionType.value = actionType;
|
||||
isBatchActionMode.value = true;
|
||||
batchActionVisible.value = true;
|
||||
}
|
||||
|
||||
// 批量审批提交(对所有选中项执行批量 API)
|
||||
async function handleBatchActionSubmit(reason: string | null) {
|
||||
async function handleBatchActionSubmit(payload: { actionType: OvertimeApprovalActionType; reason: string | null }) {
|
||||
const ids = Array.from(selectedOvertimeIds.value);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const fn =
|
||||
currentOvertimeActionType.value === 'approve'
|
||||
? fetchBatchApproveOvertimeApplication
|
||||
: fetchBatchRejectOvertimeApplication;
|
||||
payload.actionType === 'approve' ? fetchBatchApproveOvertimeApplication : fetchBatchRejectOvertimeApplication;
|
||||
|
||||
batchSubmitting.value = true;
|
||||
const { error, data } = await fn({ ids, reason });
|
||||
const { error, data } = await fn({ ids, reason: payload.reason });
|
||||
batchSubmitting.value = false;
|
||||
|
||||
if (error || !data) return;
|
||||
|
||||
batchActionVisible.value = false;
|
||||
batchDetailVisible.value = false;
|
||||
clearOvertimeSelection();
|
||||
|
||||
@@ -1206,34 +1181,16 @@ onMounted(async () => {
|
||||
show-approval-actions
|
||||
:action-loading="overtimeActionSubmitting"
|
||||
append-to-body
|
||||
@approve="openCurrentOvertimeAction('approve')"
|
||||
@reject="openCurrentOvertimeAction('reject')"
|
||||
/>
|
||||
<OvertimeApplicationActionDialog
|
||||
v-model:visible="overtimeActionVisible"
|
||||
:action-type="currentOvertimeActionType"
|
||||
:loading="overtimeActionSubmitting"
|
||||
append-to-body
|
||||
@submit="handleOvertimeActionSubmit"
|
||||
/>
|
||||
|
||||
<!-- 批量审批详情弹窗(左右箭头切换 + 通过/退回按钮) -->
|
||||
<!-- 批量审批详情弹窗(左右箭头切换 + 通过/退回按钮 + 意见输入) -->
|
||||
<OvertimeApplicationBatchDetailDialog
|
||||
v-model:visible="batchDetailVisible"
|
||||
:selected-ids="batchSelectedIds"
|
||||
:rows="overtimeApprovalRows"
|
||||
:action-loading="batchSubmitting"
|
||||
append-to-body
|
||||
@approve="openBatchActionDialog('approve')"
|
||||
@reject="openBatchActionDialog('reject')"
|
||||
/>
|
||||
|
||||
<!-- 批量审批意见/退回原因对话框 -->
|
||||
<OvertimeApplicationActionDialog
|
||||
v-model:visible="batchActionVisible"
|
||||
:action-type="currentOvertimeActionType"
|
||||
:loading="batchSubmitting"
|
||||
append-to-body
|
||||
@submit="handleBatchActionSubmit"
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export default defineConfig(configEnv => {
|
||||
return {
|
||||
base: viteEnv.VITE_BASE_URL,
|
||||
resolve: {
|
||||
dedupe: ['@univerjs/core', '@wendellhu/redi'],
|
||||
alias: {
|
||||
'~': fileURLToPath(new URL('./', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
|
||||
Reference in New Issue
Block a user