| 16 小时以前 | zhangwencui | ![]() |
| 16 小时以前 | zhangwencui | ![]() |
| src/api/hrm.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/api/process-design.ts | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/hrm/attendance-exception/form.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/hrm/attendance-exception/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/pages/mes/md/item/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
src/api/hrm.ts
@@ -188,7 +188,7 @@ export interface Shift extends HrmRow { name?: string; startTime?: string; endTime?: string; workHours?: number; crossDay?: boolean; color?: string; status?: number; remark?: string } export interface Holiday extends HrmRow { name?: string; startDate?: string; endDate?: string; type?: number; typeName?: string; isLegal?: number; remark?: string } export interface AttendanceSummary extends HrmRow { userId?: number; userName?: string; deptId?: number; deptName?: string; yearMonth?: string; workDays?: number; actualDays?: number; lateCount?: number; earlyCount?: number; absentCount?: number; overtimeHours?: number; leaveHours?: number; remark?: string } export interface AttendanceException extends HrmRow { userId?: number; userName?: string; deptName?: string; date?: string; exceptionType?: number; exceptionTypeName?: string; reason?: string; blobIds?: number[]; attachmentList?: HrmAttachment[]; status?: number; processInstanceId?: string; remark?: string; createTime?: string } export interface AttendanceException extends HrmRow { userId?: number; userName?: string; deptName?: string; date?: string; exceptionType?: number; exceptionTypeName?: string; reason?: string; blobIds?: number[]; attachmentList?: HrmAttachment[]; status?: number; exceptionDate?: string; reviewerId?: number; reviewerName?: string; reviewTime?: string; reviewRemark?: string; remark?: string; createTime?: string } export interface EmployeeSchedule extends HrmRow { employeeId?: number; employeeName?: string; employeeNo?: string; date?: string; shiftId?: number; shiftName?: string; shiftColor?: string; remark?: string } export interface SalaryStructure extends HrmRow { name?: string; overtimePayRatio?: number; restDayOvertimePayRatio?: number; legalHolidayOvertimePayRatio?: number; status?: number; remark?: string } export interface SocialSecurityScheme extends HrmRow { name?: string; effectiveDate?: string; pensionCompanyRatio?: number; pensionPersonalRatio?: number; medicalCompanyRatio?: number; medicalPersonalRatio?: number; unemploymentCompanyRatio?: number; unemploymentPersonalRatio?: number; workInjuryRatio?: number; maternityRatio?: number; housingFundCompanyRatio?: number; housingFundPersonalRatio?: number; status?: number; remark?: string } @@ -224,8 +224,7 @@ export function getAttendanceExceptionPage(token: string, params: Record<string, unknown>) { return request<PageResult<AttendanceException>>(`/hrm/attendance/exception/page${query(params as Record<string, string | number | boolean | undefined>)}`, { header: header(token) }); } export function getAttendanceException(token: string, id: number) { return request<AttendanceException>(`/hrm/attendance/exception/get${query({ id })}`, { header: header(token) }); } export function createAttendanceException(token: string, data: AttendanceException) { return request<number>("/hrm/attendance/exception/create", { method: "POST", data: data as Record<string, unknown>, header: header(token) }); } export function submitAttendanceException(token: string, id: number, processDefinitionKey: string) { return request<boolean>(`/hrm/attendance/exception/submit${query({ id, processDefinitionKey })}`, { method: "POST", header: header(token) }); } export function getAttendanceExceptionProcesses(token: string) { return request<ApproveProcess[]>("/hrm/attendance/exception/approve-process-list", { header: header(token) }); } export function submitAttendanceException(token: string, id: number) { return request<boolean>(`/hrm/attendance/exception/submit${query({ id })}`, { method: "PUT", header: header(token) }); } export function getSalaryStructureRows(token: string, params: { name?: string; status?: number } = {}) { return request<SalaryStructure[]>(`/hrm/salary/structure/list${query(params)}`, { header: header(token) }); } export function createSalaryStructure(token: string, data: SalaryStructure) { return request<number>("/hrm/salary/structure/create", { method: "POST", data: data as Record<string, unknown>, header: header(token) }); } src/api/process-design.ts
@@ -625,7 +625,16 @@ export function getMesItemPage( accessToken: string, params: { pageNo: number; pageSize: number; code?: string; name?: string; status?: number; ids?: string; itemTypeId?: number }, params: { pageNo: number; pageSize: number; code?: string; name?: string; status?: number; ids?: string; itemTypeId?: number; excludeItemTypeCode?: string; }, ) { return request<PageResult<MesMdItem>>(`/mes/md/item/page${query(params)}`, { header: header(accessToken) }); } src/pages/hrm/attendance-exception/form.vue
@@ -1,6 +1,183 @@ <script setup lang="ts"> import{computed,ref}from"vue";import{onLoad}from"@dcloudio/uni-app";import{createAttendanceException,getAttendanceException,getHrmUserList,type AttendanceException,type UserOption}from"../../../api/hrm";import{useAuth}from"../../../stores/auth";import PickerSheet from"../../mes/process-design/components/PickerSheet.vue";import HrmAttachmentField from"../HrmAttachmentField.vue";import{toast}from"../shared"; const{state}=useAuth();const form=ref<AttendanceException>({exceptionType:1,blobIds:[]}),users=ref<UserOption[]>([]),userVisible=ref(false),typeVisible=ref(false),readonly=ref(false),submitting=ref(false);const types=[{key:1,label:"补签到"},{key:2,label:"补签退"},{key:3,label:"迟到申诉"},{key:4,label:"早退申诉"},{key:5,label:"缺勤申诉"},{key:6,label:"其他"}];const userName=computed(()=>users.value.find(x=>x.id===form.value.userId)?.nickname||form.value.userName||"请选择员工"); async function save(){if(!state.accessToken||readonly.value||!form.value.userId||!form.value.date||!form.value.reason)return toast("请完整填写员工、日期和申请原因");submitting.value=true;try{await createAttendanceException(state.accessToken,form.value);toast("保存成功");setTimeout(()=>uni.navigateBack(),400);}catch(e){toast(e instanceof Error?e.message:"保存失败");}finally{submitting.value=false;}} onLoad(async q=>{if(!state.accessToken)return;readonly.value=q?.detail==='1';try{users.value=await getHrmUserList(state.accessToken);if(q?.id)form.value=await getAttendanceException(state.accessToken,Number(q.id));}catch(e){toast(e instanceof Error?e.message:"加载失败");}}); </script><template><view class="page"><view class="form-card"><view class="field"><text class="required">申请人</text><view class="picker" @click="!readonly&&(userVisible=true)"><text>{{userName}}</text><text>›</text></view></view><view class="field"><text class="required">异常日期</text><input v-model="form.date" :disabled="readonly" placeholder="YYYY-MM-DD"/></view><view class="field"><text>异常类型</text><view class="picker" @click="!readonly&&(typeVisible=true)"><text>{{types.find(x=>x.key===form.exceptionType)?.label||'请选择'}}</text><text>›</text></view></view><view class="field column"><text class="required">申请原因</text><textarea v-model="form.reason" :disabled="readonly" placeholder="请输入原因"/></view><view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly"/></view><HrmAttachmentField v-model="form.blobIds" v-model:files="form.attachmentList" :token="state.accessToken" :readonly="readonly"/></view><button v-if="!readonly" class="save" :loading="submitting" @click="save">保存草稿</button><PickerSheet v-model:show="userVisible" title="选择员工" :items="users.map(x=>({key:x.id,label:x.nickname||x.username||String(x.id),sub:x.deptName}))" :selected-keys="form.userId?[form.userId]:[]" @confirm="k=>form.userId=Number(k[0])"/><PickerSheet v-model:show="typeVisible" title="异常类型" :items="types" :selected-keys="form.exceptionType?[form.exceptionType]:[]" :searchable="false" @confirm="k=>form.exceptionType=Number(k[0])"/></view></template><style scoped lang="scss">@import "../form.scss";.field input{text-align:right}</style> import { computed, ref } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import { createAttendanceException, getAttendanceException, getAttendanceMyStatus, uploadHrmFile, type AttendanceException, type HrmAttachment } from "../../../api/hrm"; import { useAuth } from "../../../stores/auth"; import PickerSheet from "../../mes/process-design/components/PickerSheet.vue"; import { toast } from "../shared"; const { state } = useAuth(); const form = ref<AttendanceException>({ exceptionType: 1, blobIds: [] }); const typeVisible = ref(false); const readonly = ref(false); const submitting = ref(false); const attachments = ref<HrmAttachment[]>([]); const pendingImages = ref<string[]>([]); const MAX_ATTACHMENTS = 10; const IMAGE_PATTERN = /\.(png|jpe?g|gif|webp|bmp|heic)(\?.*)?$/i; const uploading = computed(() => pendingImages.value.length > 0); const types = [ { key: 1, label: "漏打卡" }, { key: 2, label: "迟到" }, { key: 3, label: "早退" }, { key: 4, label: "其他" }, ]; function isImage(file: HrmAttachment) { return IMAGE_PATTERN.test(file.url || "") || IMAGE_PATTERN.test(file.name || ""); } function chooseImages() { if (!state.accessToken || uploading.value) return; const remain = MAX_ATTACHMENTS - attachments.value.length - pendingImages.value.length; if (remain <= 0) { return toast(`最多上传${MAX_ATTACHMENTS}个附件`); } uni.chooseImage({ count: remain, sourceType: ["album", "camera"], success: async result => { const paths = result.tempFilePaths || []; if (!paths.length) return; pendingImages.value.push(...paths); try { for (const path of paths) { try { const file = await uploadHrmFile(state.accessToken!, path); attachments.value.push(file); if (file.id && !form.value.blobIds?.includes(file.id)) { form.value.blobIds = [...(form.value.blobIds || []), file.id]; } } catch (error) { toast(error instanceof Error ? error.message : "图片上传失败"); } } } finally { pendingImages.value = pendingImages.value.filter(path => !paths.includes(path)); } }, }); } function removeAttachment(index: number) { const file = attachments.value[index]; attachments.value.splice(index, 1); if (file?.id !== undefined) { form.value.blobIds = (form.value.blobIds || []).filter(blobId => blobId !== file.id); } } function previewAttachment(index: number) { const file = attachments.value[index]; if (!file?.url) return toast("附件地址不可用"); if (isImage(file)) { const urls = attachments.value.filter(item => isImage(item) && item.url).map(item => item.url as string); uni.previewImage({ urls, current: file.url }); return; } // #ifdef H5 window.open(file.url, "_blank"); // #endif // #ifndef H5 toast("非图片附件请在 Web 端查看"); // #endif } async function save() { if (!state.accessToken || readonly.value || !form.value.date || !form.value.exceptionType || !form.value.reason?.trim()) { return toast("请完整填写考勤日期、异常类型和异常原因"); } if (uploading.value) return toast("图片上传中,请稍候"); submitting.value = true; try { await createAttendanceException(state.accessToken, form.value); toast("保存成功"); setTimeout(() => uni.navigateBack(), 400); } catch (error) { toast(error instanceof Error ? error.message : "保存失败"); } finally { submitting.value = false; } } onLoad(async query => { if (!state.accessToken) return; readonly.value = query?.detail === "1"; if (!query?.id) return; try { const [status, detail] = await Promise.all([ getAttendanceMyStatus(state.accessToken), getAttendanceException(state.accessToken, Number(query.id)), ]); if (!status.employeeId || Number(detail.userId) !== Number(status.employeeId)) { toast("只能查看自己的申请"); return setTimeout(() => uni.navigateBack(), 500); } form.value = { ...detail, blobIds: detail.attachmentList?.map(item => item.id) || detail.blobIds || [] }; attachments.value = detail.attachmentList || []; } catch (error) { toast(error instanceof Error ? error.message : "加载失败"); } }); </script> <template> <view class="page"> <view class="form-card"> <view class="field"> <text>考勤日期 <text class="required">*</text></text> <picker v-if="!readonly" mode="date" :value="form.date" @change="form.date=$event.detail.value"> <view class="picker"><text>{{ form.date || "请选择考勤日期" }}</text><text>›</text></view> </picker> <text v-else class="value">{{ form.date || "--" }}</text> </view> <view class="field"> <text>异常类型 <text class="required">*</text></text> <view v-if="!readonly" class="picker" @click="typeVisible=true"> <text>{{ types.find(item => item.key === form.exceptionType)?.label || "请选择异常类型" }}</text><text>›</text> </view> <text v-else class="value">{{ form.exceptionTypeName || types.find(item => item.key === form.exceptionType)?.label || "--" }}</text> </view> <view class="field column"><text>异常原因 <text class="required">*</text></text><textarea v-model="form.reason" :disabled="readonly" placeholder="请输入异常原因" /></view> <view v-if="!readonly || attachments.length" class="field column attachment-field"> <text>图片附件 <text class="optional">(选填,最多10张)</text></text> <view class="attachment-grid"> <view v-for="(file, index) in attachments" :key="file.id" class="attachment-cell"> <image v-if="isImage(file)" :src="file.url" mode="aspectFill" @click="previewAttachment(index)" /> <view v-else class="attachment-doc" @click="previewAttachment(index)"> <text>{{ file.name || `附件${index + 1}` }}</text> </view> <text v-if="!readonly" class="attachment-del" @click="removeAttachment(index)">×</text> </view> <view v-for="(path, index) in pendingImages" :key="`pending-${index}`" class="attachment-cell"> <image :src="path" mode="aspectFill" /> <view class="attachment-mask"><text>上传中</text></view> </view> <view v-if="!readonly && attachments.length + pendingImages.length < MAX_ATTACHMENTS" class="attachment-add" @click="chooseImages"> <text class="plus">+</text> <text>上传图片</text> </view> </view> </view> <view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" placeholder="请输入备注" /></view> </view> <button v-if="!readonly" class="save" :loading="submitting" :disabled="submitting" @click="save">保存草稿</button> <PickerSheet v-model:show="typeVisible" title="异常类型" :items="types" :selected-keys="form.exceptionType?[form.exceptionType]:[]" :searchable="false" @confirm="keys=>form.exceptionType=Number(keys[0])" /> </view> </template> <style scoped lang="scss"> @import "../form.scss"; .attachment-field { gap: 18rpx; } .optional { color: #97a4b4; font-size: 22rpx; } .attachment-grid { display: flex; flex-wrap: wrap; gap: 18rpx; } .attachment-cell { position: relative; width: 150rpx; height: 150rpx; } .attachment-cell image { width: 150rpx; height: 150rpx; background: #f2f4f7; border-radius: 12rpx; } .attachment-doc { display: flex; align-items: center; justify-content: center; box-sizing: border-box; width: 150rpx; height: 150rpx; padding: 12rpx; background: #f2f6fc; border: 1rpx solid #dbe5f2; border-radius: 12rpx; } .attachment-doc text { color: #526780; font-size: 22rpx; text-align: center; word-break: break-all; } .attachment-del { position: absolute; top: -12rpx; right: -12rpx; width: 38rpx; height: 38rpx; color: #fff; font-size: 28rpx; line-height: 36rpx; text-align: center; background: rgba(0, 0, 0, 0.55); border-radius: 50%; } .attachment-mask { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.4); border-radius: 12rpx; } .attachment-mask text { color: #fff; font-size: 22rpx; } .attachment-add { display: flex; align-items: center; flex-direction: column; justify-content: center; box-sizing: border-box; width: 150rpx; height: 150rpx; gap: 8rpx; color: #7c8aa0; font-size: 22rpx; background: #f7f9fc; border: 1rpx dashed #c3cfdf; border-radius: 12rpx; } .attachment-add .plus { color: #93a3ba; font-size: 44rpx; line-height: 1; } .attachment-add:active { background: #eef3fa; } </style> src/pages/hrm/attendance-exception/index.vue
@@ -1,39 +1,42 @@ <script setup lang="ts"> import { computed, ref } from "vue"; import { ref } from "vue"; import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app"; import { getAttendanceExceptionPage, submitAttendanceException, getAttendanceExceptionProcesses, type AttendanceException, type ApproveProcess } from "../../../api/hrm"; import { getAttendanceExceptionPage, getAttendanceMyStatus, submitAttendanceException, type AttendanceException } from "../../../api/hrm"; import { useAuth } from "../../../stores/auth"; import PickerSheet from "../../mes/process-design/components/PickerSheet.vue"; import { confirm, toast } from "../shared"; const { hasPermission, state } = useAuth(); const rows = ref<AttendanceException[]>([]), processes = ref<ApproveProcess[]>([]), processVisible = ref(false), active = ref<AttendanceException>(), loading = ref(false), pageNo = ref(1), total = ref(0); const rows = ref<AttendanceException[]>([]), loading = ref(false), pageNo = ref(1), myEmployeeId = ref<number>(), hasMore = ref(true); const pageSize = 15; const canQuery = computed(() => hasPermission("hrm:attendance-exception:query")); const hasMore = computed(() => rows.value.length < total.value); const typeText = (type?: number) => ({ 1: "漏打卡", 2: "迟到", 3: "早退", 4: "其他" } as Record<number, string>)[Number(type)] || "--"; async function load(reset = true) { if (!state.accessToken || !canQuery.value || loading.value) return; if (reset) { pageNo.value = 1; total.value = 0; } if (!state.accessToken || loading.value) return; if (reset) { pageNo.value = 1; rows.value = []; hasMore.value = true; } loading.value = true; try { const data = await getAttendanceExceptionPage(state.accessToken, { pageNo: pageNo.value, pageSize }); rows.value = reset ? (data.list || []) : [...rows.value, ...(data.list || [])]; total.value = Number(data.total || 0); if (rows.value.length < total.value) pageNo.value += 1; if (reset) processes.value = await getAttendanceExceptionProcesses(state.accessToken); if (!myEmployeeId.value) { const status = await getAttendanceMyStatus(state.accessToken); myEmployeeId.value = status.employeeId; } if (!myEmployeeId.value) throw new Error("当前账号未关联员工档案"); const data = await getAttendanceExceptionPage(state.accessToken, { pageNo: pageNo.value, pageSize, userId: myEmployeeId.value }); // 即使后端查询策略调整,App 也只保留当前员工本人的申请。 const pageRows = data.list || []; const ownRows = pageRows.filter(row => Number(row.userId) === Number(myEmployeeId.value)); rows.value = reset ? ownRows : [...rows.value, ...ownRows]; hasMore.value = pageRows.length === pageSize && pageNo.value * pageSize < Number(data.total || 0); if (hasMore.value) pageNo.value += 1; } catch (error) { toast(error instanceof Error ? error.message : "加载失败"); } finally { loading.value = false; uni.stopPullDownRefresh(); } } function submit(row: AttendanceException) { active.value = row; if (processes.value.length === 1) doSubmit(processes.value[0].key); else processVisible.value = true; function go(id?: number) { uni.navigateTo({ url: `/pages/hrm/attendance-exception/form${id ? `?id=${id}&detail=1` : ""}` }); } async function doSubmit(key: string | number) { if (!state.accessToken || !active.value?.id || !await confirm("确认提交考勤异常审批?")) return; try { await submitAttendanceException(state.accessToken, active.value.id, String(key)); toast("提交成功"); processVisible.value = false; await load(true); } async function submit(row: AttendanceException) { if (!state.accessToken || !row.id || Number(row.userId) !== Number(myEmployeeId.value) || !await confirm("确认提交考勤异常审批?")) return; try { await submitAttendanceException(state.accessToken, row.id); toast("提交成功"); await load(true); } catch (error) { toast(error instanceof Error ? error.message : "提交失败"); } } onShow(() => load(true)); onPullDownRefresh(() => load(true)); onReachBottom(() => { if (hasMore.value) load(false); }); </script><template><view class="page"><template v-if="canQuery"><view v-for="row in rows" :key="row.id" class="card" @click="uni.navigateTo({url:`/pages/hrm/attendance-exception/form?id=${row.id}&detail=1`})"><view class="head"><view class="head-main"><text class="name">{{row.userName||'--'}}</text><text class="sub">{{row.deptName||'--'}} · {{row.date||'--'}}</text></view><text :class="['tag',row.status===20?'tag-green':row.status===10?'tag-blue':'tag-gray']">{{row.status===0?'草稿':row.status===10?'审批中':row.status===20?'已通过':'已结束'}}</text></view><view class="grid"><text>异常类型:{{row.exceptionTypeName||row.exceptionType||'--'}}</text><text>原因:{{row.reason||'--'}}</text><text>备注:{{row.remark||'--'}}</text></view><view class="actions" @click.stop><button @click="uni.navigateTo({url:`/pages/hrm/attendance-exception/form?id=${row.id}&detail=1`})">查看</button><button v-if="row.status===0&&hasPermission('hrm:attendance-exception:submit')" @click="submit(row)">提交</button></view></view><view v-if="!rows.length" class="state">{{loading?'加载中...':'暂无考勤异常申请'}}</view><view v-else class="state more">{{loading ? "加载中..." : hasMore ? "上拉加载更多" : "没有更多了"}}</view><button v-if="hasPermission('hrm:attendance-exception:create')" class="fab" @click="uni.navigateTo({url:'/pages/hrm/attendance-exception/form'})"><view/></button></template><view v-else class="state">暂无考勤异常查看权限</view><PickerSheet v-model:show="processVisible" title="选择审批流程" :items="processes.map(x=>({key:x.key,label:x.name}))" :selected-keys="[]" @confirm="k=>doSubmit(k[0])"/></view></template><style scoped lang="scss">@import "../list.scss";</style> </script><template><view class="page"><view v-for="row in rows" :key="row.id" class="card" @click="go(row.id)"><view class="head"><view class="head-main"><text class="name">{{row.userName||'--'}}</text><text class="sub">{{row.deptName||'--'}} · {{row.date||'--'}}</text></view><text :class="['tag',row.status===20?'tag-green':row.status===10?'tag-blue':'tag-gray']">{{row.status===0?'草稿':row.status===10?'审批中':row.status===20?'已通过':row.status===30?'未通过':'已取消'}}</text></view><view class="grid"><text>异常类型:{{row.exceptionTypeName||typeText(row.exceptionType)}}</text><text>异常原因:{{row.reason||'--'}}</text><text>备注:{{row.remark||'--'}}</text></view><view class="actions" @click.stop><button @click="go(row.id)">查看</button><button v-if="(row.status===0||row.status===30)&&hasPermission('hrm:attendance-exception:submit')" @click="submit(row)">提交</button></view></view><view v-if="!rows.length" class="state">{{loading?'加载中...':'暂无本人提交的考勤异常申请'}}</view><view v-else class="state more">{{loading ? "加载中..." : hasMore ? "上拉加载更多" : "没有更多了"}}</view><button v-if="hasPermission('hrm:attendance-exception:create')" class="fab" @click="go()"><view/></button></view></template><style scoped lang="scss">@import "../list.scss";</style> src/pages/mes/md/item/index.vue
@@ -38,6 +38,14 @@ ...flattenCategories(item.children || [], depth + 1), ]); } function excludeEquipmentCategories(list: MaterialCategory[]): MaterialCategory[] { return list .filter(item => item.code !== "EQUIPMENT") .map(item => ({ ...item, children: excludeEquipmentCategories(item.children || []), })); } function chooseCategory(id?: number) { activeTypeId.value = id; load(true); @@ -52,12 +60,15 @@ loadError.value = ""; try { if (!dicts.value.length) dicts.value = await getSimpleDictDataList(state.accessToken); if (!categories.value.length) categories.value = await getMaterialCategoryList(state.accessToken); if (!categories.value.length) { categories.value = excludeEquipmentCategories(await getMaterialCategoryList(state.accessToken)); } const data = await getMesItemPage(state.accessToken, { pageNo: pageNo.value, pageSize: 15, name: keyword.value.trim() || undefined, itemTypeId: activeTypeId.value, excludeItemTypeCode: "EQUIPMENT", }); total.value = data.total; rows.value = reset ? data.list : [...rows.value, ...data.list];