feat(新增加班申请功能): 新增申请功能,可在工作台进行审核。

fix(dict_data): 在字典数据新增、编辑时可以操作颜色类型字段(color_type)。
This commit is contained in:
dk
2026-06-01 21:37:08 +08:00
parent b2da882b31
commit d3d0830820
29 changed files with 1966 additions and 23 deletions

View File

@@ -0,0 +1,113 @@
<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' | 'cancel';
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: '退回加班申请',
cancel: '撤销加班申请'
};
return map[props.actionType];
});
const reasonLabel = computed(() => {
const map: Record<ActionType, string> = {
approve: '审核意见',
reject: '退回原因',
cancel: '撤销原因'
};
return map[props.actionType];
});
const reasonRequired = computed(() => props.actionType !== 'approve');
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="reasonRequired ? `请输入${reasonLabel}` : '可填写审核意见'"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
formatEmptyText,
formatOvertimeDate,
formatOvertimeDateTime,
getOvertimeApplicationStatusLabel,
resolveOvertimeApplicationStatusTagType
} from './overtime-application-shared';
defineOptions({ name: 'OvertimeApplicationDetailDialog' });
interface Props {
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const loading = ref(false);
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
const statusTagType = computed(() => resolveOvertimeApplicationStatusTagType(detailData.value?.statusCode));
const statusLabel = computed(() =>
getOvertimeApplicationStatusLabel(detailData.value?.statusCode, detailData.value?.statusName)
);
async function loadDetail() {
if (!props.rowData?.id) {
detailData.value = null;
return;
}
loading.value = true;
const { error, data } = await fetchGetOvertimeApplicationDetail(props.rowData.id);
loading.value = false;
detailData.value = error || !data ? props.rowData : data;
}
watch(
() => visible.value,
value => {
if (value) {
loadDetail();
}
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" title="加班申请详情" preset="md" :loading="loading" :show-footer="false">
<ElDescriptions v-if="detailData" :column="2" border>
<ElDescriptionsItem label="状态">
<ElTag :type="statusTagType">{{ statusLabel }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="申请人">
{{ detailData.applicantName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="加班日期">{{ formatOvertimeDate(detailData.overtimeDate) }}</ElDescriptionsItem>
<ElDescriptionsItem label="加班时长">{{ detailData.overtimeDuration }}</ElDescriptionsItem>
<ElDescriptionsItem label="审核人">
{{ detailData.approverName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="提交时间">{{ formatOvertimeDateTime(detailData.submitTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="审核时间">{{ formatOvertimeDateTime(detailData.approvalTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="审核意见">{{ formatEmptyText(detailData.approvalComment) }}</ElDescriptionsItem>
<ElDescriptionsItem label="加班原因" :span="2">{{ detailData.overtimeReason }}</ElDescriptionsItem>
<ElDescriptionsItem label="加班内容" :span="2">{{ detailData.overtimeContent }}</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未获取到加班申请详情" />
</BusinessFormDialog>
</template>
<style scoped>
:deep(.overtime-application-detail-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.overtime-application-detail-dialog__readonly-input .el-input__wrapper:hover),
:deep(.overtime-application-detail-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.overtime-application-detail-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
</style>

View File

@@ -0,0 +1,273 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { RDMS_OVERTIME_DURATION_DICT_CODE } from '@/constants/dict';
import {
fetchCreateOvertimeApplication,
fetchGetLoginUserDirectManager,
fetchGetOvertimeApplicationDetail,
fetchUpdateRejectedOvertimeApplication
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'OvertimeApplicationOperateDialog' });
type OperateType = 'add' | 'edit';
interface Props {
operateType: OperateType;
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
submitted: [];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const authStore = useAuthStore();
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const detailLoading = ref(false);
const submitting = ref(false);
const approverName = ref('');
const currentUserName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '--');
const isEdit = computed(() => props.operateType === 'edit');
const title = computed(() => (isEdit.value ? '修改并重新提交' : '新增加班申请'));
const model = reactive<Api.OvertimeApplication.SaveOvertimeApplicationParams>(createDefaultModel());
const rules = computed(
() =>
({
overtimeDate: [createRequiredRule('请选择加班日期')],
overtimeDuration: [createRequiredRule('请选择加班时长')],
overtimeReason: [
createRequiredRule('请输入加班原因'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入加班原因'));
return;
}
callback();
},
trigger: 'blur'
}
],
overtimeContent: [
createRequiredRule('请输入加班内容'),
{
validator: (_rule, value: string, callback) => {
if (!value?.trim()) {
callback(new Error('请输入加班内容'));
return;
}
callback();
},
trigger: 'blur'
}
],
approverId: [createRequiredRule('请选择审核人')]
}) satisfies Record<keyof Api.OvertimeApplication.SaveOvertimeApplicationParams, App.Global.FormRule[]>
);
function createDefaultModel(): Api.OvertimeApplication.SaveOvertimeApplicationParams {
return {
overtimeDate: '',
overtimeDuration: '',
overtimeReason: '',
overtimeContent: '',
approverId: ''
};
}
async function loadDirectManagerAsDefaultApprover() {
const { error, data } = await fetchGetLoginUserDirectManager();
if (!error && data?.id) {
model.approverId = data.id;
approverName.value = data.nickname;
}
}
async function initModel() {
detailLoading.value = true;
Object.assign(model, createDefaultModel());
approverName.value = '';
if (isEdit.value && props.rowData) {
const { error, data } = await fetchGetOvertimeApplicationDetail(props.rowData.id);
const detail = error || !data ? props.rowData : data;
model.overtimeDate = detail.overtimeDate;
model.overtimeDuration = detail.overtimeDuration;
model.overtimeReason = detail.overtimeReason;
model.overtimeContent = detail.overtimeContent;
model.approverId = detail.approverId;
approverName.value = detail.approverName;
} else {
await loadDirectManagerAsDefaultApprover();
}
detailLoading.value = false;
await nextTick();
formRef.value?.clearValidate();
}
async function handleSubmit() {
await validate();
const payload: Api.OvertimeApplication.SaveOvertimeApplicationParams = {
overtimeDate: model.overtimeDate,
overtimeDuration: model.overtimeDuration,
overtimeReason: model.overtimeReason.trim(),
overtimeContent: model.overtimeContent.trim(),
approverId: model.approverId
};
submitting.value = true;
const result =
isEdit.value && props.rowData
? await fetchUpdateRejectedOvertimeApplication(props.rowData.id, payload)
: await fetchCreateOvertimeApplication(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success(isEdit.value ? '加班申请已重新提交' : '加班申请已提交');
visible.value = false;
emit('submitted');
}
watch(
() => visible.value,
value => {
if (value) {
initModel();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="md"
:loading="detailLoading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<BusinessFormSection title="申请信息">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="申请人">
<ElInput
class="overtime-application-operate-dialog__readonly-input"
:model-value="currentUserName"
readonly
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="审核人" prop="approverId">
<ElInput
class="overtime-application-operate-dialog__readonly-input"
:model-value="approverName"
readonly
placeholder="暂无直属上级"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="加班日期" prop="overtimeDate" style="width: 100%">
<ElDatePicker
v-model="model.overtimeDate"
class="w-full"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择加班日期"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="加班时长" prop="overtimeDuration">
<DictSelect
v-model="model.overtimeDuration"
:dict-code="RDMS_OVERTIME_DURATION_DICT_CODE"
placeholder="请选择加班时长"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection title="加班说明">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem label="加班原因" prop="overtimeReason">
<ElInput
v-model="model.overtimeReason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入加班原因"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="加班内容" prop="overtimeContent">
<ElInput
v-model="model.overtimeContent"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入加班内容"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.overtime-application-operate-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.overtime-application-operate-dialog__readonly-input .el-input__wrapper:hover),
:deep(.overtime-application-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.overtime-application-operate-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE } from '@/constants/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'OvertimeApplicationSearch' });
const emit = defineEmits<{
reset: [];
search: [];
}>();
const model = defineModel<Api.OvertimeApplication.OvertimeApplicationSearchParams>('model', {
required: true
});
const searchModel = reactive<Record<string, any>>({
applicantName: '',
overtimeDate: undefined,
statusCode: undefined,
approverName: ''
});
let syncingFromSource = false;
watch(
() =>
[model.value.applicantName, model.value.overtimeDate, model.value.statusCode, model.value.approverName] as const,
([applicantName, overtimeDate, statusCode, approverName]) => {
syncingFromSource = true;
searchModel.applicantName = applicantName ?? '';
searchModel.overtimeDate = overtimeDate;
searchModel.statusCode = statusCode;
searchModel.approverName = approverName ?? '';
syncingFromSource = false;
},
{ immediate: true, flush: 'sync' }
);
watch(
() =>
[searchModel.applicantName, searchModel.overtimeDate, searchModel.statusCode, searchModel.approverName] as const,
([applicantName, overtimeDate, statusCode, approverName]) => {
if (syncingFromSource) {
return;
}
model.value.applicantName = applicantName?.trim() || undefined;
model.value.overtimeDate = overtimeDate;
model.value.statusCode = statusCode;
model.value.approverName = approverName?.trim() || undefined;
},
{ flush: 'sync' }
);
const fields = computed<SearchField[]>(() => [
{
key: 'applicantName',
label: '申请人',
type: 'input',
placeholder: '请输入申请人'
},
{
key: 'overtimeDate',
label: '加班日期',
type: 'dateRange',
placeholder: '请选择加班日期'
},
{
key: 'statusCode',
label: '状态',
type: 'dict',
dictCode: RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE,
placeholder: '请选择状态'
},
{
key: 'approverName',
label: '审核人',
type: 'input',
placeholder: '请输入审核人'
}
]);
function handleReset() {
emit('reset');
}
function handleSearch() {
emit('search');
}
</script>
<template>
<TableSearchFields v-model="searchModel" :fields="fields" :columns="4" @reset="handleReset" @search="handleSearch" />
</template>

View File

@@ -0,0 +1,77 @@
import dayjs from 'dayjs';
import { getStatusTagType } from '@/constants/status-tag';
export const overtimeApplicationStatusOptions: Array<{
label: string;
value: Api.OvertimeApplication.OvertimeApplicationStatusCode;
}> = [
{ label: '待审批', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已退回', value: 'rejected' },
{ label: '已撤销', value: 'cancelled' }
];
export const overtimeApplicationActionNameMap: Record<Api.OvertimeApplication.OvertimeApplicationActionType, string> = {
submit: '提交',
resubmit: '重新提交',
approve: '通过',
reject: '退回',
cancel: '撤销'
};
export function getOvertimeApplicationStatusLabel(statusCode?: string | null, statusName?: string | null) {
if (statusName) {
return statusName;
}
return overtimeApplicationStatusOptions.find(item => item.value === statusCode)?.label || statusCode || '--';
}
export function resolveOvertimeApplicationStatusTagType(statusCode?: string | null) {
return getStatusTagType('overtimeApplication', statusCode);
}
export function getOvertimeApplicationActionLabel(actionType?: string | null) {
if (!actionType) {
return '--';
}
return (
overtimeApplicationActionNameMap[actionType as Api.OvertimeApplication.OvertimeApplicationActionType] || actionType
);
}
export function formatOvertimeDate(value?: string | null) {
if (!value) {
return '--';
}
const target = dayjs(value);
return target.isValid() ? target.format('YYYY-MM-DD') : value;
}
export function formatOvertimeDateTime(value?: string | null) {
if (!value) {
return '--';
}
const target = dayjs(value);
return target.isValid() ? target.format('YYYY-MM-DD HH:mm:ss') : value;
}
export function formatEmptyText(value?: string | null) {
return value?.trim() || '--';
}
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);
}

View File

@@ -0,0 +1,89 @@
<script setup lang="tsx">
import { ref, watch } from 'vue';
import { ElTag } from 'element-plus';
import { fetchGetOvertimeApplicationStatusLogs } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
formatEmptyText,
formatOvertimeDate,
formatOvertimeDateTime,
getOvertimeApplicationActionLabel,
getOvertimeApplicationStatusLabel,
resolveOvertimeApplicationStatusTagType
} from './overtime-application-shared';
defineOptions({ name: 'OvertimeApplicationStatusLogDialog' });
interface Props {
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const loading = ref(false);
const logs = ref<Api.OvertimeApplication.OvertimeApplicationStatusLog[]>([]);
async function loadLogs() {
if (!props.rowData?.id) {
logs.value = [];
return;
}
loading.value = true;
const { error, data } = await fetchGetOvertimeApplicationStatusLogs(props.rowData.id);
loading.value = false;
logs.value = error || !data ? [] : data;
}
function renderStatus(code?: string | null) {
if (!code) {
return '--';
}
return <ElTag type={resolveOvertimeApplicationStatusTagType(code)}>{getOvertimeApplicationStatusLabel(code)}</ElTag>;
}
watch(
() => visible.value,
value => {
if (value) {
loadLogs();
}
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="状态日志"
width="920px"
:loading="loading"
:show-footer="false"
max-body-height="72vh"
>
<ElTable border :data="logs">
<ElTableColumn prop="createTime" label="操作时间" width="170">
<template #default="{ row }">{{ formatOvertimeDateTime(row.createTime) }}</template>
</ElTableColumn>
<ElTableColumn prop="actionType" label="动作" width="110">
<template #default="{ row }">{{ getOvertimeApplicationActionLabel(row.actionType) }}</template>
</ElTableColumn>
<ElTableColumn prop="operatorName" label="操作人" width="120" show-overflow-tooltip />
<ElTableColumn prop="fromStatus" label="原状态" width="110" :formatter="row => renderStatus(row.fromStatus)" />
<ElTableColumn prop="toStatus" label="新状态" width="110" :formatter="row => renderStatus(row.toStatus)" />
<ElTableColumn prop="reason" label="原因/意见" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ formatEmptyText(row.reason) }}</template>
</ElTableColumn>
<ElTableColumn prop="overtimeDateSnapshot" label="加班日期" width="120">
<template #default="{ row }">{{ formatOvertimeDate(row.overtimeDateSnapshot) }}</template>
</ElTableColumn>
<ElTableColumn prop="overtimeDurationSnapshot" label="时长" width="90" show-overflow-tooltip />
</ElTable>
</BusinessFormDialog>
</template>