Преглед на файлове

feat: 添加考勤申请类型映射和文件上传功能,优化表单处理逻辑

xieyong преди 1 седмица
родител
ревизия
1c02041c1f
променени са 4 файла, в които са добавени 452 реда и са изтрити 33 реда
  1. 161 11
      src/api/attendance.js
  2. 72 0
      src/api/file.js
  3. 139 22
      src/pages/apply/form.vue
  4. 80 0
      src/utils/auth.js

+ 161 - 11
src/api/attendance.js

@@ -24,6 +24,48 @@ export const REPAIR_TYPE_LABEL = {
 }
 export const REPAIR_TYPE_OPTIONS = Object.keys(REPAIR_TYPE_LABEL)
 
+// 表单本地 type → 接口 applyType 枚举的映射。
+// 这 6 种都走 /hr/attendance/applications,其他类型(resign/transfer/regular/certificate)
+// 走另外的流程,本文件不负责。
+export const ATTENDANCE_APPLY_TYPE_MAP = {
+  leave: 'LEAVE',
+  overtime: 'OVERTIME',
+  field: 'OUTING', // 外勤
+  trip: 'TRIP',
+  shift: 'SHIFT_CHANGE',
+  check: 'CARD_REPAIR',
+}
+export const ATTENDANCE_APPLY_TYPE_KEYS = Object.keys(ATTENDANCE_APPLY_TYPE_MAP)
+
+// 表单 leaveType 中文标签 → 接口 leaveType 枚举
+export const LEAVE_TYPE_MAP = {
+  '年假': 'ANNUAL',
+  '事假': 'PERSONAL',
+  '病假': 'SICK',
+  '调休假': 'COMPENSATORY',
+  '婚假': 'MARRIAGE',
+}
+
+// 默认上下班时间:用于请假/出差/外出等申请(用户没动时间 picker 时的兜底)
+const DEFAULT_START_TIME = '08:30'
+const DEFAULT_END_TIME = '17:30'
+
+// 把 yyyy-MM-dd + HH:mm 拼成 ISO 字符串(本地时区),空时间用占位符
+function buildRangeTime(dateStr, timeStr) {
+  if (!dateStr) return ''
+  return combineDateTime(dateStr, timeStr || '00:00')
+}
+
+// 按开始/结束的日期+时间算出真实分钟数(不是按 8h/天粗估)
+function computeRangeMinutes(startDate, startTime, endDate, endTime) {
+  if (!startDate || !endDate) return 0
+  const start = new Date(`${startDate}T${startTime || '00:00'}:00`)
+  const end = new Date(`${endDate}T${endTime || '00:00'}:00`)
+  if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 0
+  if (end < start) return 0
+  return Math.floor((end - start) / 60000)
+}
+
 const STATUS_OPTIONS = ['NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'LATE', 'LEAVE', 'ABSENT']
 
 // ============ 时间格式化辅助 ============
@@ -330,18 +372,126 @@ export { toHHmm as formatTimeOf }
 // ============ 补卡申请 ============
 
 /**
- * 提交补卡申请
+ * 把表单数据按 applyType 映射成 /hr/attendance/applications 接口参数。
+ * 仅处理 ATTENDANCE_APPLY_TYPE_MAP 中的 6 种考勤类型;其他类型返回 null。
+ *
+ * 表单字段:
+ *   - leave:    leaveType
+ *   - check:    startDate + repairTime + repairType(特殊:startTime = repairPunchTime = combineDateTime(startDate, repairTime))
+ *   - 其他 4 类:startDate + startTime + endDate + endTime(开始/结束时间由用户在表单里挑)
+ *
+ * @param {object} form            表单 reactive(leaveType / certificateType / startDate / startTime / endDate / endTime / repairType / repairTime / reason / attachments)
+ * @param {string} type            表单本地类型键(leave / overtime / field / trip / shift / check)
+ * @param {object} currentUser     当前登录用户(useCurrentUser 返回值,含 .raw / .id / .name)
+ * @param {string} attachmentFileIds 逗号分隔的附件主键串(来自 uploadAttachment / 上传流程)
+ * @returns {object|null}          提交参数;type 不在考勤类型里返回 null
+ */
+export function buildAttendancePayload(form, type, currentUser, attachmentFileIds) {
+  const applyType = ATTENDANCE_APPLY_TYPE_MAP[type]
+  if (!applyType) return null
+
+  const user = currentUser || {}
+  const userId =
+    (user.raw && user.raw.userId) ||
+    (user.raw && user.raw.id) ||
+    user.id ||
+    ''
+  const userName = user.name || ''
+
+  const base = { userId, userName, applyType, attachmentFileIds: attachmentFileIds || '' }
+
+  switch (type) {
+    case 'leave':
+      return {
+        ...base,
+        leaveType: LEAVE_TYPE_MAP[form.leaveType] || 'OTHER',
+        startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
+        endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
+        durationMinutes: computeRangeMinutes(
+          form.startDate,
+          form.startTime || DEFAULT_START_TIME,
+          form.endDate,
+          form.endTime || DEFAULT_END_TIME,
+        ),
+        reason: form.reason,
+      }
+    case 'overtime':
+      // overtimeType 暂时按工作日兜底(TODO: 表单里加选择器)
+      return {
+        ...base,
+        overtimeType: 'WORKDAY',
+        startTime: buildRangeTime(form.startDate, form.startTime || '18:00'),
+        endTime: buildRangeTime(form.endDate, form.endTime || '20:00'),
+        durationMinutes: computeRangeMinutes(
+          form.startDate,
+          form.startTime || '18:00',
+          form.endDate,
+          form.endTime || '20:00',
+        ),
+        reason: form.reason,
+      }
+    case 'trip':
+      // TODO: 表单里加 needPunch(出差是否需要异地打卡)开关
+      return {
+        ...base,
+        startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
+        endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
+        durationMinutes: computeRangeMinutes(
+          form.startDate,
+          form.startTime || DEFAULT_START_TIME,
+          form.endDate,
+          form.endTime || DEFAULT_END_TIME,
+        ),
+        needPunch: 0,
+        reason: form.reason,
+      }
+    case 'field': // OUTING(外勤)
+      // TODO: 表单里加 syncAsPunch(外出是否同步为上下班卡)开关
+      return {
+        ...base,
+        startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
+        endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
+        durationMinutes: computeRangeMinutes(
+          form.startDate,
+          form.startTime || DEFAULT_START_TIME,
+          form.endDate,
+          form.endTime || DEFAULT_END_TIME,
+        ),
+        syncAsPunch: 0,
+        reason: form.reason,
+      }
+    case 'shift':
+      // TODO: 表单里加原班次/目标班次/换班目标员工选择器
+      return {
+        ...base,
+        startTime: buildRangeTime(form.startDate, form.startTime || '00:00'),
+        endTime: buildRangeTime(form.endDate, form.endTime || '23:59'),
+        reason: form.reason,
+      }
+    case 'check':
+      return {
+        ...base,
+        startTime: combineDateTime(form.startDate, form.repairTime),
+        repairPunchTime: combineDateTime(form.startDate, form.repairTime),
+        repairType: form.repairType,
+        reason: form.reason,
+      }
+    default:
+      return null
+  }
+}
+
+/**
+ * 提交考勤类申请(请假 / 加班 / 出差 / 外出 / 补卡 / 调班)
  * POST /hr/attendance/applications
- * @param {{
- *   userId: number|string,         // 申请员工主键(int64,服务端期望)
- *   userName?: string,             // 员工姓名快照
- *   applyType: string,             // 固定 CARD_REPAIR
- *   startTime: string,             // ISO date-time,业务开始时间
- *   repairPunchTime: string,       // ISO date-time,实际补卡打卡时间
- *   repairType: string,            // MISSING_IN / MISSING_OUT / LATE / EARLY / ABSENT / OTHER / NORMAL
- *   reason?: string,
- * }} payload
- * @returns {Promise<object>} 服务端返回的保存结果(考勤申请保存返回模型)
+ *
+ * 接口语义(来自后端):
+ *   "请假、加班、出差、外出、补卡和调班仅保存待发起事实;附件须先上传至统一文件服务,
+ *    再以 attachmentFileIds 提交并由服务端校验。前端随后传 hr_attendance_application
+ *    和申请主键调用 /bpm/process-instance/create,审批终态由统一流程事件受控回写"
+ *
+ * @param {object} payload 见 buildAttendancePayload 的返回结构
+ * @returns {Promise<object>} 服务端返回的保存结果(考勤申请保存返回模型,含 id 主键)
  */
 export async function submitAttendanceApplication(payload) {
   const body = { ...payload }

+ 72 - 0
src/api/file.js

@@ -0,0 +1,72 @@
+import {
+  getApiBaseUrl,
+  getServerConfig,
+  uploadFile,
+} from "@/utils/auth";
+
+// 模式判断 + 服务器路径:与 src/api/attendance.js 保持同样形态
+function isServerMode() {
+  return getServerConfig().mode === "server";
+}
+
+function serverPath() {
+  const base = getApiBaseUrl();
+  if (!base) throw new Error("缺少服务器地址,请先在登录页右上角配置");
+  return base;
+}
+
+// demo 模式合成一个文件主键,避免与真实 id 撞车(带 demo- 前缀)
+let demoFileIdCounter = 0;
+function nextDemoFileId() {
+  demoFileIdCounter += 1;
+  return `demo-${Date.now()}-${demoFileIdCounter}`;
+}
+
+function basename(path) {
+  if (!path) return "";
+  const normalized = String(path).replace(/\\/g, "/");
+  const idx = normalized.lastIndexOf("/");
+  return idx >= 0 ? normalized.slice(idx + 1) : normalized;
+}
+
+/**
+ * 上传单个文件到统一文件服务
+ * POST /main/file/uploadFile?module=...
+ * 表单字段名:multiPartFile
+ *
+ * 调用方负责在 UI 上跟踪每张图的状态(uploading → success/failed),
+ * 选图后立刻调用此接口,无需在提交时再批量串一次。
+ *
+ * @param {string} filePath  本地文件路径(来自 uni.chooseImage / chooseFile)
+ * @param {string} [moduleName='hr_attendance']  所属模块,写入 query string
+ * @returns {Promise<{ id: number|string, name: string, url: string, size?: number, type?: string, storePath?: string, module?: string, createTime?: string, createUserId?: string|number, taskId?: string }>}
+ */
+export async function uploadAttachment(filePath, moduleName = "hr_attendance") {
+  if (!filePath) throw new Error("缺少文件路径");
+
+  if (!isServerMode()) {
+    // demo 模式:150ms 模拟网络延迟,返回合成主键
+    await new Promise((r) => setTimeout(r, 150));
+    const now = new Date().toISOString();
+    return {
+      id: nextDemoFileId(),
+      name: basename(filePath) || `mock-${Date.now()}`,
+      size: 0,
+      type: "",
+      url: filePath,
+      storePath: filePath,
+      module: moduleName,
+      createTime: now,
+      createUserId: "",
+      taskId: "",
+    };
+  }
+
+  const url = `${serverPath()}/main/file/uploadFile?module=${encodeURIComponent(moduleName)}`;
+  const res = await uploadFile({ url, filePath, name: "multiPartFile" });
+  const data = res && res.data;
+  if (!data || data.id === undefined || data.id === null) {
+    throw new Error("文件上传响应缺少主键");
+  }
+  return data;
+}

+ 139 - 22
src/pages/apply/form.vue

@@ -54,10 +54,20 @@
               class="value">{{ form.startDate }}</text></picker><text class="arrow">›</text>
         </view>
         <view class="divider"></view>
+        <view class="field"><text class="label required">开始时间</text>
+          <picker mode="time" :value="form.startTime" @change="(e) => (form.startTime = e.detail.value)"><text
+              class="value">{{ form.startTime || "请选择" }}</text></picker><text class="arrow">›</text>
+        </view>
+        <view class="divider"></view>
         <view class="field"><text class="label required">结束日期</text>
           <picker mode="date" :value="form.endDate" @change="(e) => (form.endDate = e.detail.value)"><text
               class="value">{{ form.endDate }}</text></picker><text class="arrow">›</text>
         </view>
+        <view class="divider"></view>
+        <view class="field"><text class="label required">结束时间</text>
+          <picker mode="time" :value="form.endTime" @change="(e) => (form.endTime = e.detail.value)"><text
+              class="value">{{ form.endTime || "请选择" }}</text></picker><text class="arrow">›</text>
+        </view>
       </template>
       <view class="divider"></view>
       <view class="field textarea-field"><text class="label"
@@ -67,7 +77,7 @@
     <view class="form-card card section">
       <view class="field"><text class="label">附件</text>
         <view class="upload press" @click="chooseImage"><text class="plus">+</text><text>拍照或上传</text></view>
-        <view v-if="form.attachment" class="file-name">已选择 1 张图片</view>
+        <view v-if="attachmentSummary" class="file-name">{{ attachmentSummary }}</view>
       </view>
       <view class="divider"></view>
       <view class="field"><text class="label">审批人</text>
@@ -95,11 +105,13 @@ import {
   saveDraft as persistDraft,
 } from "@/utils/storage";
 import {
+  ATTENDANCE_APPLY_TYPE_MAP,
   REPAIR_TYPE_LABEL,
   REPAIR_TYPE_OPTIONS,
-  combineDateTime,
+  buildAttendancePayload,
   submitAttendanceApplication,
 } from "@/api/attendance";
+import { uploadAttachment } from "@/api/file";
 import { getServerConfig } from "@/utils/auth";
 const type = ref("leave");
 useAuthGuard();
@@ -108,9 +120,11 @@ const form = reactive({
   leaveType: "",
   certificateType: "",
   startDate: "2026-08-06",
+  startTime: "08:30",
   endDate: "2026-08-06",
+  endTime: "17:30",
   reason: "",
-  attachment: "",
+  attachments: [],
   repairType: "",
   repairTime: "",
 });
@@ -127,6 +141,16 @@ const reasonPlaceholder = computed(() =>
 const repairTypeLabel = computed(() =>
   form.repairType ? REPAIR_TYPE_LABEL[form.repairType] : "请选择",
 );
+const attachmentSummary = computed(() => {
+  const list = Array.isArray(form.attachments) ? form.attachments : [];
+  if (!list.length) return "";
+  const uploading = list.filter((a) => a && a.status === "uploading").length;
+  const failed = list.filter((a) => a && a.status === "failed").length;
+  const parts = [`已选择 ${list.length} 张图片`];
+  if (uploading) parts.push(`${uploading} 上传中`);
+  if (failed) parts.push(`${failed} 失败`);
+  return parts.join(" · ");
+});
 function syncType(o = {}) {
   const next = o.type || "leave";
   if (type.value !== next) resetForm();
@@ -134,6 +158,24 @@ function syncType(o = {}) {
   uni.setNavigationBarTitle({ title: config.value.title });
   const draft = getDraft(next);
   if (draft) Object.assign(form, draft);
+  // 草稿兼容:
+  //   - 老草稿没有 startTime / endTime 字段 → 兜底默认值,避免 validate 误报
+  //   - 老草稿字段叫 attachment(字符串)→ 已不在 form 上,忽略
+  //   - 老格式(新字段 attachments)但值为字符串数组(重命名前的中间态)→ 清空让用户重选
+  //   - 跨会话遗留的 uploading 状态 → 视为失败(upload 早已中断)
+  if (!form.startTime) form.startTime = "08:30";
+  if (!form.endTime) form.endTime = "17:30";
+  if (!Array.isArray(form.attachments)) {
+    form.attachments = [];
+  } else if (form.attachments.some((a) => typeof a === "string")) {
+    form.attachments = [];
+  } else {
+    form.attachments = form.attachments.map((a) =>
+      a && a.status === "uploading"
+        ? { ...a, status: "failed", error: "上次会话未完成" }
+        : a,
+    );
+  }
 }
 onLoad(syncType);
 onShow(() => {
@@ -159,9 +201,11 @@ function resetForm() {
     leaveType: "",
     certificateType: "",
     startDate: "2026-08-06",
+    startTime: "08:30",
     endDate: "2026-08-06",
+    endTime: "17:30",
     reason: "",
-    attachment: "",
+    attachments: [],
     repairType: "",
     repairTime: "",
   });
@@ -189,8 +233,27 @@ function chooseRepairType() {
 }
 function chooseImage() {
   uni.chooseImage({
-    count: 1,
-    success: (r) => (form.attachment = r.tempFilePaths[0]),
+    count: 9,
+    success: (r) => {
+      const paths = (r.tempFilePaths || []).filter(Boolean);
+      if (!paths.length) return;
+      // 立即把每张图作为 uploading 项加入列表,触发并行上传,
+      // 状态变化(success/failed)由 Promise 回调直接写回对应条目。
+      const items = paths.map((p) => ({ localPath: p, status: "uploading" }));
+      form.attachments = [...form.attachments, ...items];
+      items.forEach((item) => {
+        uploadAttachment(item.localPath, "hr_attendance")
+          .then((file) => {
+            item.status = "success";
+            item.id = String(file?.id ?? "");
+          })
+          .catch((e) => {
+            item.status = "failed";
+            item.error = e?.message || "上传失败";
+            uni.showToast({ title: item.error, icon: "none" });
+          });
+      });
+    },
   });
 }
 function saveDraft() {
@@ -207,8 +270,15 @@ function validate() {
     if (!form.repairTime) return "请选择打卡时间";
     if (!form.reason) return "请填写补卡原因";
   }
-  if (!["certificate", "check"].includes(type.value) && form.endDate < form.startDate)
-    return "结束日期不能早于开始日期";
+  if (!["certificate", "check"].includes(type.value)) {
+    if (!form.startDate) return "请选择开始日期";
+    if (!form.endDate) return "请选择结束日期";
+    if (!form.startTime) return "请选择开始时间";
+    if (!form.endTime) return "请选择结束时间";
+    if (form.endDate < form.startDate) return "结束日期不能早于开始日期";
+    if (form.endDate === form.startDate && form.endTime <= form.startTime)
+      return "结束时间不能早于或等于开始时间";
+  }
   if (!form.reason && type.value === "certificate") return "";
   if (!form.reason && !["certificate", "check"].includes(type.value))
     return "请填写申请事由";
@@ -218,25 +288,72 @@ async function submit() {
   const error = validate();
   if (error) return uni.showToast({ title: error, icon: "none" });
 
-  // 补卡申请:服务器模式先调 POST /hr/attendance/applications
-  if (type.value === "check" && getServerConfig().mode === "server") {
+  // 服务器模式 + 考勤类申请(请假 / 加班 / 出差 / 外出 / 补卡 / 调班)
+  // → POST /hr/attendance/applications(附件已在 chooseImage 时上传完成)
+  if (
+    ATTENDANCE_APPLY_TYPE_MAP[type.value] &&
+    getServerConfig().mode === "server"
+  ) {
     try {
+      // 等待所有进行中的上传完成(chooseImage 触发的后台任务)
+      const pending = form.attachments.filter(
+        (a) => a && a.status === "uploading",
+      );
+      if (pending.length) {
+        uni.showLoading({ title: `附件上传中(${pending.length})` });
+        await Promise.all(
+          pending.map((item) =>
+            uploadAttachment(item.localPath, "hr_attendance")
+              .then((file) => {
+                item.status = "success";
+                item.id = String(file?.id ?? "");
+              })
+              .catch((e) => {
+                item.status = "failed";
+                item.error = e?.message || "上传失败";
+                throw e;
+              }),
+          ),
+        );
+        uni.hideLoading();
+      }
+
+      // 有失败的附件就拦截提交
+      const failed = form.attachments.filter((a) => a && a.status === "failed");
+      if (failed.length) {
+        return uni.showToast({
+          title: `有 ${failed.length} 张附件上传失败,请重新选择`,
+          icon: "none",
+        });
+      }
+
+      const attachmentFileIds = form.attachments
+        .filter((a) => a && a.status === "success" && a.id)
+        .map((a) => a.id)
+        .join(",");
+
       uni.showLoading({ title: "提交中..." });
-      const user = currentUser || {};
-      const userId = (user && user.raw && user.raw.userId) || (user && user.id) || "";
-      await submitAttendanceApplication({
-        userId,
-        userName: user.name,
-        applyType: "CARD_REPAIR",
-        startTime: combineDateTime(form.startDate, form.repairTime),
-        repairPunchTime: combineDateTime(form.startDate, form.repairTime),
-        repairType: form.repairType,
-        reason: form.reason,
-      });
+      const payload = buildAttendancePayload(
+        form,
+        type.value,
+        currentUser,
+        attachmentFileIds,
+      );
+      if (!payload) {
+        uni.hideLoading();
+        return uni.showToast({
+          title: "申请类型不支持",
+          icon: "none",
+        });
+      }
+      await submitAttendanceApplication(payload);
       uni.hideLoading();
     } catch (e) {
       uni.hideLoading();
-      return uni.showToast({ title: e.message || "补卡提交失败", icon: "none" });
+      return uni.showToast({
+        title: e.message || "申请提交失败",
+        icon: "none",
+      });
     }
   }
 

+ 80 - 0
src/utils/auth.js

@@ -120,6 +120,86 @@ export function request({
   });
 }
 
+/**
+ * multipart/form-data 上传封装,对应 /main/file/uploadFile 等需要走 uni.uploadFile 的接口。
+ * 与 request() 的差异:
+ *   - 不写 content-type(uni-app 会自动设 multipart/form-data + boundary)
+ *   - 走 uni.uploadFile,文件路径用 filePath,表单字段名用 name(默认 multiPartFile)
+ *   - H5 端响应体可能是 JSON 字符串,需要 typeof === 'string' 时 JSON.parse
+ * 401、code !== 0 等失败语义与 request() 完全一致,调用方可统一处理。
+ */
+export function uploadFile({
+  url,
+  filePath,
+  name = "multiPartFile",
+  formData,
+  timeout = 30000,
+}) {
+  return new Promise((resolve, reject) => {
+    if (!filePath) {
+      reject(new Error("缺少文件路径"));
+      return;
+    }
+    const { token, sessionId } = getAuthCredentials();
+    const header = token
+      ? {
+          "zoomwin-token": token,
+          Authorization: token,
+          "zoomwin-sid": sessionId || "",
+        }
+      : { platform: "wxapp" };
+
+    uni.uploadFile({
+      url,
+      filePath,
+      name,
+      formData,
+      header,
+      timeout,
+      success: (response) => {
+        if (response.statusCode === 401) {
+          logout();
+          clearAttendanceCache();
+          uni.showToast({
+            title: "登录已过期,请重新登录",
+            icon: "none",
+            duration: 1500,
+          });
+          setTimeout(() => uni.reLaunch({ url: "/pages/login/index" }), 1200);
+          reject(new Error("身份验证已过期,请重新登录"));
+          return;
+        }
+        let body = response.data;
+        if (typeof body === "string") {
+          try {
+            body = JSON.parse(body);
+          } catch {
+            // 不是 JSON:保持原字符串,下方按非 2xx/message 处理
+          }
+        }
+        const payload = body && typeof body === "object" ? body : {};
+        if (
+          response.statusCode < 200 ||
+          response.statusCode >= 300
+        ) {
+          reject(
+            new Error(
+              payload.message || `服务器响应异常(${response.statusCode})`,
+            ),
+          );
+          return;
+        }
+        if (payload.code !== undefined && Number(payload.code) !== 0) {
+          reject(new Error(payload.message || "请求失败"));
+          return;
+        }
+        resolve(payload);
+      },
+      fail: (error) => reject(new Error(error.errMsg || "文件上传失败")),
+    });
+  });
+}
+
 export async function testServerConnection(config) {
   const normalized = {
     mode: "server",