zhangwencui
16 小时以前 460b5e97e60a5c917a98ef831f4f4805ed8b0216
考勤异常申请功能
已修改3个文件
237 ■■■■■ 文件已修改
src/api/hrm.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/attendance-exception/form.vue 187 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/attendance-exception/index.vue 45 ●●●●● 补丁 | 查看 | 原始文档 | 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/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>