Procházet zdrojové kódy

feat:【bpm】oa 请假:10% 初始化

YunaiV před 4 měsíci
rodič
revize
fd723685d9

+ 35 - 0
src/api/bpm/oa/leave/index.ts

@@ -0,0 +1,35 @@
+import type { PageParam, PageResult } from '@/http/types'
+import { http } from '@/http/http'
+
+/** 请假申请 */
+export interface Leave {
+  id: number
+  status: number
+  type: number
+  reason: string
+  processInstanceId: string
+  startTime: number
+  endTime: number
+  createTime: number
+  startUserSelectAssignees?: Record<string, string[]>
+}
+
+/** 创建请假申请 */
+export function createLeave(data: Partial<Leave>) {
+  return http.post<number>('/bpm/oa/leave/create', data)
+}
+
+/** 更新请假申请 */
+export function updateLeave(data: Partial<Leave>) {
+  return http.put<boolean>('/bpm/oa/leave/update', data)
+}
+
+/** 获得请假申请 */
+export function getLeave(id: number) {
+  return http.get<Leave>(`/bpm/oa/leave/get?id=${id}`)
+}
+
+/** 获得请假申请分页 */
+export function getLeavePage(params: PageParam) {
+  return http.get<PageResult<Leave>>('/bpm/oa/leave/page', params)
+}

+ 173 - 0
src/pages-bpm/oa/leave/components/search-form.vue

@@ -0,0 +1,173 @@
+<template>
+  <!-- 搜索框入口 -->
+  <view @click="visible = true">
+    <wd-search :placeholder="placeholder" hide-cancel disabled />
+  </view>
+
+  <!-- 搜索弹窗 -->
+  <wd-popup v-model="visible" position="top" @close="visible = false">
+    <view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
+      <!-- 请假类型 -->
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          请假类型
+        </view>
+        <wd-radio-group v-model="formData.type" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)" :key="dict.value" :value="dict.value">
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <!-- 审批结果 -->
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          审批结果
+        </view>
+        <wd-radio-group v-model="formData.status" shape="button">
+          <wd-radio :value="-1">
+            全部
+          </wd-radio>
+          <wd-radio v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)" :key="dict.value" :value="dict.value">
+            {{ dict.label }}
+          </wd-radio>
+        </wd-radio-group>
+      </view>
+      <!-- 创建时间 -->
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          创建时间
+        </view>
+        <view class="yd-search-form-date-range-container">
+          <view class="flex-1" @click="visibleCreateTime[0] = true">
+            <view class="yd-search-form-date-range-picker">
+              {{ formatDate(formData.createTime?.[0]) || '开始日期' }}
+            </view>
+          </view>
+          -
+          <view class="flex-1" @click="visibleCreateTime[1] = true">
+            <view class="yd-search-form-date-range-picker">
+              {{ formatDate(formData.createTime?.[1]) || '结束日期' }}
+            </view>
+          </view>
+        </view>
+        <wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
+        <view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
+          <wd-button size="small" plain @click="visibleCreateTime[0] = false">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
+            确定
+          </wd-button>
+        </view>
+        <wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
+        <view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
+          <wd-button size="small" plain @click="visibleCreateTime[1] = false">
+            取消
+          </wd-button>
+          <wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
+            确定
+          </wd-button>
+        </view>
+      </view>
+      <!-- 请假原因 -->
+      <view class="yd-search-form-item">
+        <view class="yd-search-form-label">
+          请假原因
+        </view>
+        <wd-input
+          v-model="formData.reason"
+          placeholder="请输入请假原因"
+          clearable
+        />
+      </view>
+      <!-- 操作按钮 -->
+      <view class="yd-search-form-actions">
+        <wd-button class="flex-1" plain @click="handleReset">
+          重置
+        </wd-button>
+        <wd-button class="flex-1" type="primary" @click="handleSearch">
+          搜索
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref } from 'vue'
+import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
+import { getNavbarHeight } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDate, formatDateRange } from '@/utils/date'
+
+const emit = defineEmits<{
+  search: [data: Record<string, any>]
+  reset: []
+}>()
+
+const visible = ref(false)
+const formData = reactive({
+  type: -1, // -1 表示全部
+  status: -1, // -1 表示全部
+  createTime: [undefined, undefined] as [number | undefined, number | undefined],
+  reason: undefined as string | undefined,
+})
+
+/** 搜索条件 placeholder 拼接 */
+const placeholder = computed(() => {
+  const conditions: string[] = []
+  if (formData.type !== -1) {
+    conditions.push(`类型:${getDictLabel(DICT_TYPE.BPM_OA_LEAVE_TYPE, formData.type)}`)
+  }
+  if (formData.status !== -1) {
+    conditions.push(`状态:${getDictLabel(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS, formData.status)}`)
+  }
+  if (formData.createTime?.[0] && formData.createTime?.[1]) {
+    conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
+  }
+  if (formData.reason) {
+    conditions.push(`原因:${formData.reason}`)
+  }
+  return conditions.length > 0 ? conditions.join(' | ') : '搜索请假记录'
+})
+
+// 时间选择器状态
+const visibleCreateTime = ref<[boolean, boolean]>([false, false])
+const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
+
+/** 创建时间[0]确认 */
+function handleCreateTime0Confirm() {
+  formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
+  visibleCreateTime.value[0] = false
+}
+
+/** 创建时间[1]确认 */
+function handleCreateTime1Confirm() {
+  formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
+  visibleCreateTime.value[1] = false
+}
+
+/** 搜索 */
+function handleSearch() {
+  visible.value = false
+  emit('search', {
+    type: formData.type === -1 ? undefined : formData.type,
+    status: formData.status === -1 ? undefined : formData.status,
+    reason: formData.reason || undefined,
+    createTime: formatDateRange(formData.createTime),
+  })
+}
+
+/** 重置 */
+function handleReset() {
+  formData.type = -1
+  formData.status = -1
+  formData.createTime = [undefined, undefined]
+  formData.reason = undefined
+  visible.value = false
+  emit('reset')
+}
+</script>

+ 220 - 0
src/pages-bpm/oa/leave/create/index.vue

@@ -0,0 +1,220 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      :title="formData.id ? '编辑请假' : '发起请假'"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 表单内容 -->
+    <view class="p-24rpx">
+      <!-- 基本信息卡片 -->
+      <view class="overflow-hidden rounded-16rpx bg-white">
+        <view class="p-24rpx">
+          <view class="mb-24rpx text-32rpx text-[#333] font-bold">
+            请假信息
+          </view>
+          <!-- 请假类型 -->
+          <view class="mb-24rpx">
+            <view class="mb-12rpx text-28rpx text-[#333]">
+              <text class="text-[#ff4d4f]">*</text> 请假类型
+            </view>
+            <wd-picker
+              v-model="formData.type"
+              :columns="leaveTypeOptions"
+              label=""
+              placeholder="请选择请假类型"
+            />
+          </view>
+          <!-- 开始时间 -->
+          <view class="mb-24rpx">
+            <view class="mb-12rpx text-28rpx text-[#333]">
+              <text class="text-[#ff4d4f]">*</text> 开始时间
+            </view>
+            <wd-datetime-picker
+              v-model="formData.startTime"
+              type="datetime"
+              label=""
+              placeholder="请选择开始时间"
+            />
+          </view>
+          <!-- 结束时间 -->
+          <view class="mb-24rpx">
+            <view class="mb-12rpx text-28rpx text-[#333]">
+              <text class="text-[#ff4d4f]">*</text> 结束时间
+            </view>
+            <wd-datetime-picker
+              v-model="formData.endTime"
+              type="datetime"
+              label=""
+              placeholder="请选择结束时间"
+            />
+          </view>
+          <!-- 请假原因 -->
+          <view>
+            <view class="mb-12rpx text-28rpx text-[#333]">
+              <text class="text-[#ff4d4f]">*</text> 请假原因
+            </view>
+            <wd-textarea
+              v-model="formData.reason"
+              placeholder="请输入请假原因"
+              :maxlength="200"
+              show-word-limit
+            />
+          </view>
+        </view>
+      </view>
+
+      <!-- TODO:流程预览卡片 -->
+      <!-- 原始 vben 版本有流程节点预览和发起人选择审批人功能 -->
+      <!-- 参考:yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/oa/leave/create.vue 第 40-50 行 -->
+      <!-- uniapp 端暂不实现流程节点预览,因为需要复杂的 ProcessInstanceTimeline 组件 -->
+      <view class="mt-24rpx overflow-hidden rounded-16rpx bg-white">
+        <view class="p-24rpx">
+          <view class="mb-16rpx text-32rpx text-[#333] font-bold">
+            流程信息
+          </view>
+          <view class="text-28rpx text-[#999]">
+            提交后将进入审批流程
+          </view>
+          <!-- TODO:实现流程节点预览 -->
+          <!-- 参考:yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/oa/leave/create.vue 第 40-50 行 -->
+          <!-- 需要实现 ProcessInstanceTimeline 组件和 getApprovalDetail API -->
+        </view>
+      </view>
+    </view>
+
+    <!-- 底部提交按钮 -->
+    <view class="yd-detail-footer">
+      <view class="yd-detail-footer-actions">
+        <wd-button type="default" class="flex-1" @click="handleBack">
+          取消
+        </wd-button>
+        <wd-button type="primary" class="flex-1" :loading="formLoading" @click="handleSubmit">
+          提交
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Leave } from '@/api/bpm/oa/leave'
+import { onLoad } from '@dcloudio/uni-app'
+import { computed, ref } from 'vue'
+import { useMessage, useToast } from 'wot-design-uni'
+import { createLeave, updateLeave } from '@/api/bpm/oa/leave'
+import { getIntDictOptions } from '@/hooks/useDict'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const message = useMessage()
+const formLoading = ref(false)
+
+const formData = ref<Partial<Leave>>({
+  type: undefined,
+  startTime: undefined,
+  endTime: undefined,
+  reason: undefined,
+})
+
+/** 请假类型选项 */
+const leaveTypeOptions = computed(() => {
+  return getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE).map(item => ({
+    label: item.label,
+    value: item.value,
+  }))
+})
+
+/** 返回上一页 */
+function handleBack() {
+  message.confirm({
+    title: '提示',
+    msg: '确定要返回吗?请先保存您填写的信息!',
+  }).then(({ action }) => {
+    if (action === 'confirm') {
+      navigateBackPlus('/pages-bpm/oa/leave/index')
+    }
+  })
+}
+
+/** 表单校验 */
+function validateForm(): boolean {
+  if (formData.value.type === undefined) {
+    toast.show('请选择请假类型')
+    return false
+  }
+  if (!formData.value.startTime) {
+    toast.show('请选择开始时间')
+    return false
+  }
+  if (!formData.value.endTime) {
+    toast.show('请选择结束时间')
+    return false
+  }
+  if (formData.value.startTime >= formData.value.endTime) {
+    toast.show('结束时间必须大于开始时间')
+    return false
+  }
+  if (!formData.value.reason?.trim()) {
+    toast.show('请输入请假原因')
+    return false
+  }
+  return true
+}
+
+/** 提交表单 */
+async function handleSubmit() {
+  if (!validateForm()) {
+    return
+  }
+
+  // TODO:校验发起人选择审批人
+  // 参考:yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/oa/leave/create.vue 第 68-78 行
+  // uniapp 端暂不实现发起人选择审批人功能
+
+  try {
+    formLoading.value = true
+    const submitData: Partial<Leave> = {
+      ...formData.value,
+      startTime: Number(formData.value.startTime),
+      endTime: Number(formData.value.endTime),
+    }
+
+    if (formData.value.id) {
+      await updateLeave(submitData)
+    } else {
+      await createLeave(submitData)
+    }
+
+    uni.showToast({ title: '提交成功', icon: 'success' })
+    // 返回列表页
+    setTimeout(() => {
+      uni.navigateBack()
+    }, 1500)
+  } catch (error) {
+    console.error('[leave create] 提交失败:', error)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 初始化 */
+onLoad((options) => {
+  // 如果有 id 参数,则为编辑模式
+  if (options?.id) {
+    // TODO:加载请假详情进行编辑
+    // 参考:yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/oa/leave/create.vue
+    toast.show('编辑功能开发中')
+  }
+})
+</script>

+ 122 - 0
src/pages-bpm/oa/leave/detail/index.vue

@@ -0,0 +1,122 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="请假详情"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 加载中 -->
+    <view v-if="loading" class="flex items-center justify-center py-100rpx">
+      <wd-loading />
+    </view>
+
+    <!-- 详情内容 -->
+    <view v-else class="p-24rpx">
+      <!-- 基本信息卡片 -->
+      <view class="overflow-hidden rounded-16rpx bg-white">
+        <view class="p-24rpx">
+          <view class="mb-24rpx text-32rpx text-[#333] font-bold">
+            请假信息
+          </view>
+          <!-- 请假类型 -->
+          <view class="mb-16rpx flex items-center">
+            <text class="w-160rpx text-28rpx text-[#999]">请假类型</text>
+            <dict-tag :type="DICT_TYPE.BPM_OA_LEAVE_TYPE" :value="formData.type" />
+          </view>
+          <!-- 开始时间 -->
+          <view class="mb-16rpx flex items-center">
+            <text class="w-160rpx text-28rpx text-[#999]">开始时间</text>
+            <text class="text-28rpx text-[#333]">{{ formatDateTime(formData.startTime) }}</text>
+          </view>
+          <!-- 结束时间 -->
+          <view class="mb-16rpx flex items-center">
+            <text class="w-160rpx text-28rpx text-[#999]">结束时间</text>
+            <text class="text-28rpx text-[#333]">{{ formatDateTime(formData.endTime) }}</text>
+          </view>
+          <!-- 请假原因 -->
+          <view class="flex">
+            <text class="w-160rpx text-28rpx text-[#999]">请假原因</text>
+            <text class="flex-1 text-28rpx text-[#333]">{{ formData.reason }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 审批状态卡片 -->
+      <view class="mt-24rpx overflow-hidden rounded-16rpx bg-white">
+        <view class="p-24rpx">
+          <view class="mb-24rpx text-32rpx text-[#333] font-bold">
+            审批状态
+          </view>
+          <view class="flex items-center">
+            <text class="w-160rpx text-28rpx text-[#999]">当前状态</text>
+            <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="formData.status" />
+          </view>
+        </view>
+      </view>
+
+      <!-- 查看审批进度按钮 -->
+      <view v-if="formData.processInstanceId" class="mt-24rpx">
+        <wd-button type="primary" block @click="handleProgress">
+          查看审批进度
+        </wd-button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Leave } from '@/api/bpm/oa/leave'
+import { onLoad } from '@dcloudio/uni-app'
+import { ref } from 'vue'
+import { useToast } from 'wot-design-uni'
+import { getLeave } from '@/api/bpm/oa/leave'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const toast = useToast()
+const loading = ref(false)
+const formData = ref<Partial<Leave>>({})
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages-bpm/oa/leave/index')
+}
+
+/** 查看审批进度 */
+function handleProgress() {
+  if (formData.value.processInstanceId) {
+    uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${formData.value.processInstanceId}` })
+  }
+}
+
+/** 获取详情数据 */
+async function getDetailData(id: number) {
+  try {
+    loading.value = true
+    formData.value = await getLeave(id)
+  } catch (error) {
+    console.error('[leave detail] 获取详情失败:', error)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 初始化 */
+onLoad((options) => {
+  if (!options?.id) {
+    toast.show('参数错误')
+    return
+  }
+  getDetailData(Number(options.id))
+})
+</script>

+ 226 - 0
src/pages-bpm/oa/leave/index.vue

@@ -0,0 +1,226 @@
+<template>
+  <view class="yd-page-container">
+    <!-- 顶部导航栏 -->
+    <wd-navbar
+      title="请假列表"
+      left-arrow placeholder safe-area-inset-top fixed
+      @click-left="handleBack"
+    />
+
+    <!-- 搜索组件 -->
+    <LeaveSearchForm @search="handleSearch" @reset="handleReset" />
+
+    <view class="bpm-list">
+      <!-- 请假列表 -->
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="bpm-card"
+        @click="handleDetail(item)"
+      >
+        <view class="bpm-card-content">
+          <view class="bpm-card-header">
+            <view class="bpm-card-title">
+              <dict-tag :type="DICT_TYPE.BPM_OA_LEAVE_TYPE" :value="item.type" />
+            </view>
+            <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="item.status" />
+          </view>
+          <view class="bpm-summary">
+            <view class="bpm-summary-item">
+              <text class="text-[#999]">开始时间:</text>
+              <text>{{ formatDateTime(item.startTime) }}</text>
+            </view>
+            <view class="bpm-summary-item">
+              <text class="text-[#999]">结束时间:</text>
+              <text>{{ formatDateTime(item.endTime) }}</text>
+            </view>
+            <view class="bpm-summary-item">
+              <text class="text-[#999]">请假原因:</text>
+              <text>{{ item.reason }}</text>
+            </view>
+          </view>
+          <view class="bpm-card-info">
+            <view class="bpm-user">
+              <view class="bpm-avatar">
+                {{ userNickname?.[0] }}
+              </view>
+              <text class="bpm-nickname">{{ userNickname }}</text>
+            </view>
+            <text class="bpm-time">{{ formatDateTime(item.createTime) }}</text>
+          </view>
+        </view>
+        <!-- 操作按钮 -->
+        <view class="bpm-actions">
+          <view class="bpm-action-btn" @click.stop="handleDetail(item)">
+            <wd-icon name="eye-on" size="32rpx" />
+            <text class="ml-8rpx">详情</text>
+          </view>
+          <view class="bpm-action-btn" @click.stop="handleProgress(item)">
+            <wd-icon name="flow" size="32rpx" />
+            <text class="ml-8rpx">审批进度</text>
+          </view>
+          <view
+            v-if="item.status === BpmProcessInstanceStatus.RUNNING"
+            class="bpm-action-btn text-[#ff4d4f]!"
+            @click.stop="handleCancel(item)"
+          >
+            <wd-icon name="close" size="32rpx" color="#ff4d4f" />
+            <text class="ml-8rpx">取消</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadMoreState !== 'loading' && list.length === 0" class="bpm-empty">
+        <wd-status-tip image="content" tip="暂无请假记录" />
+      </view>
+      <wd-loadmore
+        v-if="list.length > 0"
+        :state="loadMoreState"
+        @reload="loadMore"
+      />
+
+      <!-- 新增按钮 -->
+      <view
+        class="fixed bottom-100rpx right-32rpx z-10 h-100rpx w-100rpx flex items-center justify-center rounded-full bg-[#1890ff] shadow-lg"
+        @click="handleCreate"
+      >
+        <wd-icon name="add" size="24px" color="#fff" />
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import type { Leave } from '@/api/bpm/oa/leave'
+import type { LoadMoreState } from '@/http/types'
+import { onReachBottom } from '@dcloudio/uni-app'
+import { computed, onMounted, ref } from 'vue'
+import { useMessage } from 'wot-design-uni'
+import { getLeavePage } from '@/api/bpm/oa/leave'
+import { cancelProcessInstanceByStartUser } from '@/api/bpm/processInstance'
+import { useUserStore } from '@/store'
+import { navigateBackPlus } from '@/utils'
+import { DICT_TYPE } from '@/utils/constants'
+import { formatDateTime } from '@/utils/date'
+import LeaveSearchForm from './components/search-form.vue'
+import '@/pages/bpm/styles/index.scss'
+
+/** 流程实例状态枚举 */
+const BpmProcessInstanceStatus = {
+  RUNNING: 1, // 审批中
+  APPROVE: 2, // 审批通过
+  REJECT: 3, // 审批不通过
+  CANCEL: 4, // 已取消
+}
+
+definePage({
+  style: {
+    navigationBarTitleText: '',
+    navigationStyle: 'custom',
+  },
+})
+
+const userStore = useUserStore()
+const message = useMessage()
+const userNickname = computed(() => userStore.userInfo?.nickname || '')
+
+const total = ref(0)
+const list = ref<Leave[]>([])
+const loadMoreState = ref<LoadMoreState>('loading')
+
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+})
+
+/** 返回上一页 */
+function handleBack() {
+  navigateBackPlus('/pages/bpm/index')
+}
+
+/** 查询列表 */
+async function getList() {
+  loadMoreState.value = 'loading'
+  try {
+    const data = await getLeavePage(queryParams.value)
+    list.value = [...list.value, ...data.list]
+    total.value = data.total
+    loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
+  } catch {
+    queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
+    loadMoreState.value = 'error'
+  }
+}
+
+/** 加载更多 */
+function loadMore() {
+  if (loadMoreState.value === 'finished') {
+    return
+  }
+  queryParams.value.pageNo++
+  getList()
+}
+
+/** 搜索 */
+function handleSearch(data?: Record<string, any>) {
+  queryParams.value = {
+    ...data,
+    pageNo: 1,
+    pageSize: queryParams.value.pageSize,
+  }
+  list.value = []
+  getList()
+}
+
+/** 重置 */
+function handleReset() {
+  handleSearch()
+}
+
+/** 查看详情 */
+function handleDetail(item: Leave) {
+  uni.navigateTo({ url: `/pages-bpm/oa/leave/detail/index?id=${item.id}` })
+}
+
+/** 审批进度 */
+function handleProgress(item: Leave) {
+  uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${item.processInstanceId}` })
+}
+
+/** 取消请假 */
+function handleCancel(item: Leave) {
+  message.confirm({
+    title: '取消流程',
+    msg: '确定要取消该请假申请吗?',
+  }).then(async ({ action }) => {
+    if (action !== 'confirm') {
+      return
+    }
+    // TODO:原始 vben 版本支持输入取消原因,uniapp 的 message.confirm 不支持输入框
+    // 参考:yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/oa/leave/index.vue 第 35-60 行
+    try {
+      await cancelProcessInstanceByStartUser(String(item.id), '用户取消')
+      uni.showToast({ title: '取消成功', icon: 'success' })
+      handleSearch()
+    } catch (error) {
+      console.error('[leave] 取消失败:', error)
+    }
+  })
+}
+
+/** 发起请假 */
+function handleCreate() {
+  uni.navigateTo({ url: '/pages-bpm/oa/leave/create/index' })
+}
+
+/** 触底加载更多 */
+onReachBottom(() => {
+  loadMore()
+})
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>