first commit

This commit is contained in:
2026-04-14 15:06:26 +08:00
commit 9ee0c6c597
582 changed files with 61051 additions and 0 deletions

46
src/App.ku.vue Normal file
View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useThemeStore } from '@/store'
import FgTabbar from '@/tabbar/index.vue'
import { isPageTabbar } from './tabbar/store'
import { currRoute } from './utils'
const themeStore = useThemeStore()
const isCurrentPageTabbar = ref(true)
onShow(() => {
console.log('App.ku.vue onShow', currRoute())
const { path } = currRoute()
// “蜡笔小开心”提到本地是 '/pages/index/index',线上是 '/' 导致线上 tabbar 不见了
// 所以这里需要判断一下,如果是 '/' 就当做首页,也要显示 tabbar
if (path === '/') {
isCurrentPageTabbar.value = true
}
else {
isCurrentPageTabbar.value = isPageTabbar(path)
}
})
const helloKuRoot = ref('Hello AppKuVue')
const exposeRef = ref('this is form app.Ku.vue')
defineExpose({
exposeRef,
})
</script>
<template>
<wd-config-provider :theme-vars="themeStore.themeVars" :theme="themeStore.theme">
<!-- 这个先隐藏了知道这样用就行 -->
<view class="hidden text-center">
{{ helloKuRoot }}这里可以配置全局的东西
</view>
<KuRootView />
<FgTabbar v-if="isCurrentPageTabbar" />
<wd-toast />
<wd-message-box />
</wd-config-provider>
</template>

26
src/App.vue Normal file
View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
import { navigateToInterceptor } from '@/router/interceptor'
onLaunch((options) => {
console.log('App.vue onLaunch', options)
})
onShow((options) => {
console.log('App.vue onShow', options)
// 处理直接进入页面路由的情况如h5直接输入路由、微信小程序分享后进入等
// https://github.com/unibest-tech/unibest/issues/192
if (options?.path) {
navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
}
else {
navigateToInterceptor.invoke({ url: '/' })
}
})
onHide(() => {
console.log('App Hide')
})
</script>
<style lang="scss">
</style>

View File

@@ -0,0 +1,51 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
const baseUrl = '/bpm/category'
/** 流程分类 */
export interface Category {
id?: number
name: string // 分类名
code: string // 分类标志
status: number // 分类状态
description?: string // 分类描述
sort: number // 分类排序
createTime?: Date
}
/** 获取流程分类分页列表 */
export function getCategoryPage(params: PageParam) {
return http.get<PageResult<Category>>(`${baseUrl}/page`, params)
}
/** 获取流程分类详情 */
export function getCategory(id: number) {
return http.get<Category>(`${baseUrl}/get?id=${id}`)
}
/** 创建流程分类 */
export function createCategory(data: Category) {
return http.post<number>(`${baseUrl}/create`, data)
}
/** 更新流程分类 */
export function updateCategory(data: Category) {
return http.put<boolean>(`${baseUrl}/update`, data)
}
/** 删除流程分类 */
export function deleteCategory(id: number) {
return http.delete<boolean>(`${baseUrl}/delete?id=${id}`)
}
/** 获取流程分类简单列表 */
export function getCategorySimpleList() {
return http.get<Category[]>(`${baseUrl}/simple-list`)
}
/** 批量修改流程分类的排序 */
export function updateCategorySortBatch(ids: number[]) {
const params = ids.join(',')
return http.put<boolean>(`${baseUrl}/update-sort-batch?ids=${params}`)
}

View File

@@ -0,0 +1,26 @@
import { http } from '@/http/http'
/** 流程定义 */
export interface ProcessDefinition {
id: string
key: string
name: string
description?: string
icon?: string
category: string
formType?: number
formId?: number
formCustomCreatePath?: string
formCustomViewPath?: string
suspensionState: number
}
/** 获取流程定义列表 */
export function getProcessDefinitionList(params?: { suspensionState?: number }) {
return http.get<ProcessDefinition[]>('/bpm/process-definition/list', params)
}
/** 获取流程定义详情 */
export function getProcessDefinition(id?: string, key?: string) {
return http.get<ProcessDefinition>('/bpm/process-definition/get', { id, key })
}

View File

@@ -0,0 +1,30 @@
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: Date | any
endTime: Date | any
createTime: Date
startUserSelectAssignees?: Record<string, string[]>
}
/** 创建请假申请 */
export function createLeave(data: Partial<Leave>) {
return http.post<number>('/bpm/oa/leave/create', 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)
}

View File

@@ -0,0 +1,38 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
const baseUrl = '/bpm/process-expression'
/** 流程表达式 */
export interface ProcessExpression {
id?: number
name: string // 表达式名字
status: number // 表达式状态
expression: string // 表达式
createTime?: Date
}
/** 获取流程表达式分页列表 */
export function getProcessExpressionPage(params: PageParam) {
return http.get<PageResult<ProcessExpression>>(`${baseUrl}/page`, params)
}
/** 获取流程表达式详情 */
export function getProcessExpression(id: number) {
return http.get<ProcessExpression>(`${baseUrl}/get?id=${id}`)
}
/** 创建流程表达式 */
export function createProcessExpression(data: ProcessExpression) {
return http.post<number>(`${baseUrl}/create`, data)
}
/** 更新流程表达式 */
export function updateProcessExpression(data: ProcessExpression) {
return http.put<boolean>(`${baseUrl}/update`, data)
}
/** 删除流程表达式 */
export function deleteProcessExpression(id: number) {
return http.delete<boolean>(`${baseUrl}/delete?id=${id}`)
}

View File

@@ -0,0 +1,41 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
const baseUrl = '/bpm/process-listener'
/** 流程监听器 */
export interface ProcessListener {
id?: number
name: string // 监听器名字
type: string // 监听器类型
status: number // 监听器状态
event: string // 监听事件
valueType: string // 监听器值类型
value: string // 监听器值
createTime?: Date
}
/** 获取流程监听器分页列表 */
export function getProcessListenerPage(params: PageParam) {
return http.get<PageResult<ProcessListener>>(`${baseUrl}/page`, params)
}
/** 获取流程监听器详情 */
export function getProcessListener(id: number) {
return http.get<ProcessListener>(`${baseUrl}/get?id=${id}`)
}
/** 创建流程监听器 */
export function createProcessListener(data: ProcessListener) {
return http.post<number>(`${baseUrl}/create`, data)
}
/** 更新流程监听器 */
export function updateProcessListener(data: ProcessListener) {
return http.put<boolean>(`${baseUrl}/update`, data)
}
/** 删除流程监听器 */
export function deleteProcessListener(id: number) {
return http.delete<boolean>(`${baseUrl}/delete?id=${id}`)
}

View File

@@ -0,0 +1,141 @@
import type { Task } from '@/api/bpm/task'
import type { PageParam, PageResult } from '@/http/types'
import type {
BpmCandidateStrategyEnum,
BpmNodeTypeEnum,
} from '@/utils/constants'
import { http } from '@/http/http'
/** 流程实例用户信息 */
export interface User {
id: number
nickname: string
avatar?: string
deptName?: string
}
/** 流程定义 */
export interface ProcessDefinition {
id: string
key: string
name: string
description?: string
icon?: string
category: string
formType?: number
formId?: number
formCustomCreatePath?: string
formCustomViewPath?: string
suspensionState: number
}
/** 流程实例 */
export interface ProcessInstance {
id: string
name: string
status: number
category?: string
categoryName?: string
createTime?: number
startTime?: number
endTime?: number
startUser?: User
businessKey?: string
processDefinition?: ProcessDefinition
summary?: {
key: string
value: string
}[]
}
/** 审批详情 */
export interface ApprovalDetail {
processInstance: ProcessInstance
processDefinition: ProcessDefinition
activityNodes: ApprovalNodeInfo[]
todoTask: Task
}
/** 审批详情的节点信息 */
export interface ApprovalNodeInfo {
candidateStrategy?: BpmCandidateStrategyEnum
candidateUsers?: User[]
endTime?: Date
id: string
name: string
nodeType: BpmNodeTypeEnum
startTime?: Date
status: number
processInstanceId?: string
tasks: ApprovalTaskInfo[]
}
/** 审批详情的节点的任务 */
export interface ApprovalTaskInfo {
id: number
assigneeUser: User
ownerUser: User
reason: string
signPicUrl: string
status: number
}
/** 抄送流程实例 */
export interface ProcessInstanceCopy {
id: string
processInstanceId: string
processInstanceName: string
startUser: User
createTime: number
summary?: {
key: string
value: string
}[]
}
/** 查询我发起的流程分页列表 */
export function getProcessInstanceMyPage(params: PageParam) {
return http.get<PageResult<ProcessInstance>>('/bpm/process-instance/my-page', params)
}
/** 查询抄送我的流程分页列表 */
export function getProcessInstanceCopyPage(params: PageParam) {
return http.get<PageResult<ProcessInstanceCopy>>('/bpm/process-instance/copy/page', params)
}
/** 查询流程实例详情 */
export function getProcessInstance(id: string) {
return http.get<ProcessInstance>(`/bpm/process-instance/get?id=${id}`)
}
/** 获取审批详情 */
export function getApprovalDetail(params: { processDefinitionId?: string, processInstanceId?: string, activityId?: string, taskId?: string, processVariablesStr?: string }) {
return http.get<ApprovalDetail>('/bpm/process-instance/get-approval-detail', params)
}
/** 新增流程实例 */
export function createProcessInstance(data: {
processDefinitionId: string
variables: Record<string, any>
}) {
return http.post<string>('/bpm/process-instance/create', data)
}
/** 申请人取消流程实例 */
export function cancelProcessInstanceByStartUser(id: string, reason: string) {
return http.delete<boolean>('/bpm/process-instance/cancel-by-start-user', { id, reason })
}
/** 查询管理员流程实例分页 */
export function getProcessInstanceManagerPage(params: PageParam) {
return http.get<PageResult<ProcessInstance>>('/bpm/process-instance/manager-page', params)
}
/** 管理员取消流程实例 */
export function cancelProcessInstanceByAdmin(id: string, reason: string) {
return http.delete<boolean>('/bpm/process-instance/cancel-by-admin', { id, reason })
}
/** 获取下一个节点审批人 */
export function getNextApproveNodes(params) {
return http.get<ApprovalNodeInfo[]>('/bpm/process-instance/get-next-approval-nodes', params)
}

102
src/api/bpm/task/index.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { ProcessInstance } from '@/api/bpm/processInstance'
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 任务处理人 */
// TODO @芋艿:貌似暂时不需要这个?!
export interface TaskUser {
id: number
nickname: string
avatar?: string
deptName?: string
}
/** 操作按钮设置 */
export interface OperationButtonSetting {
displayName: string // 按钮名称
enable: boolean // 是否启用
}
/** 流程任务 */
export interface Task {
id: string
name: string
status: number
createTime: Date
endTime?: Date
durationInMillis?: number // 持续时间
reason?: string
assigneeUser?: TaskUser
ownerUser?: TaskUser
processInstanceId?: string // 流程实例 ID
processInstance: ProcessInstance
reasonRequire?: boolean // 是否填写审批意见
signEnable?: boolean // 是否需要签名
buttonsSetting?: Record<number, OperationButtonSetting> // 按钮设置
children?: Task[] // 由加签生成,包含多层子任务
}
/** 查询待办任务分页列表 */
export function getTaskTodoPage(params: PageParam) {
return http.get<PageResult<Task>>('/bpm/task/todo-page', params)
}
/** 查询已办任务分页列表 */
export function getTaskDonePage(params: PageParam) {
return http.get<PageResult<Task>>('/bpm/task/done-page', params)
}
/** 审批通过 */
export function approveTask(data: {
id: string
reason: string
signPicUrl?: string // 签名图片 URL
nextAssignees?: Record<string, number[]> // 下一个节点审批人
}) {
return http.put<boolean>('/bpm/task/approve', data)
}
/** 审批拒绝 */
export function rejectTask(data: { id: string, reason: string }) {
return http.put<boolean>('/bpm/task/reject', data)
}
/** 根据流程实例 ID 查询任务列表 */
export function getTaskListByProcessInstanceId(processInstanceId: string) {
return http.get<Task[]>(`/bpm/task/list-by-process-instance-id?processInstanceId=${processInstanceId}`)
}
/** 查询任务管理分页 */
export function getTaskManagerPage(params: PageParam) {
return http.get<PageResult<Task>>('/bpm/task/manager-page', params)
}
/** 委派任务 */
export function delegateTask(data: { id: string, delegateUserId: string, reason: string }) {
return http.put<boolean>('/bpm/task/delegate', data)
}
/** 转办任务 */
export function transferTask(data: { id: string, assigneeUserId: string, reason: string }) {
return http.put<boolean>('/bpm/task/transfer', data)
}
/** 退回任务 */
export function returnTask(data: { id: string, targetTaskDefinitionKey: string, reason: string }) {
return http.put<boolean>('/bpm/task/return', data)
}
/** 获取可退回的节点列表 */
export function getTaskListByReturn(taskId: string) {
return http.get<any[]>(`/bpm/task/list-by-return?id=${taskId}`)
}
/** 加签任务 */
export function signCreateTask(data: { id: string, type: string, userIds: number[], reason: string }) {
return http.put<boolean>('/bpm/task/create-sign', data)
}
/** 减签任务 */
export function signDeleteTask(data: { id: string, reason: string }) {
return http.delete<boolean>('/bpm/task/delete-sign', data)
}

View File

@@ -0,0 +1,45 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
const baseUrl = '/bpm/user-group'
/** 用户组 */
export interface UserGroup {
id?: number
name: string // 组名
description: string // 描述
userIds: number[] // 成员用户编号数组
status: number // 状态
remark: string // 备注
createTime?: Date
}
/** 获取用户组分页列表 */
export function getUserGroupPage(params: PageParam) {
return http.get<PageResult<UserGroup>>(`${baseUrl}/page`, params)
}
/** 获取用户组详情 */
export function getUserGroup(id: number) {
return http.get<UserGroup>(`${baseUrl}/get?id=${id}`)
}
/** 创建用户组 */
export function createUserGroup(data: UserGroup) {
return http.post<number>(`${baseUrl}/create`, data)
}
/** 更新用户组 */
export function updateUserGroup(data: UserGroup) {
return http.put<boolean>(`${baseUrl}/update`, data)
}
/** 删除用户组 */
export function deleteUserGroup(id: number) {
return http.delete<boolean>(`${baseUrl}/delete?id=${id}`)
}
/** 获取用户组简单列表 */
export function getUserGroupSimpleList() {
return http.get<UserGroup[]>(`${baseUrl}/simple-list`)
}

View File

@@ -0,0 +1,36 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** API 访问日志信息 */
export interface ApiAccessLog {
id: number
traceId: string
userId: number
userType: number
applicationName: string
requestMethod: string
requestParams: string
responseBody: string
requestUrl: string
userIp: string
userAgent: string
operateModule: string
operateName: string
operateType: number
beginTime: Date
endTime: Date
duration: number
resultCode: number
resultMsg: string
createTime: Date
}
/** 获取 API 访问日志分页列表 */
export function getApiAccessLogPage(params: PageParam) {
return http.get<PageResult<ApiAccessLog>>('/infra/api-access-log/page', params)
}
/** 获取 API 访问日志详情 */
export function getApiAccessLog(id: number) {
return http.get<ApiAccessLog>(`/infra/api-access-log/get?id=${id}`)
}

View File

@@ -0,0 +1,45 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** API 错误日志信息 */
export interface ApiErrorLog {
id: number
traceId: string
userId: number
userType: number
applicationName: string
requestMethod: string
requestParams: string
requestUrl: string
userIp: string
userAgent: string
exceptionTime: Date
exceptionName: string
exceptionMessage: string
exceptionRootCauseMessage: string
exceptionStackTrace: string
exceptionClassName: string
exceptionFileName: string
exceptionMethodName: string
exceptionLineNumber: number
processUserId: number
processStatus: number
processTime: Date
resultCode: number
createTime: Date
}
/** 获取 API 错误日志分页列表 */
export function getApiErrorLogPage(params: PageParam) {
return http.get<PageResult<ApiErrorLog>>('/infra/api-error-log/page', params)
}
/** 获取 API 错误日志详情 */
export function getApiErrorLog(id: number) {
return http.get<ApiErrorLog>(`/infra/api-error-log/get?id=${id}`)
}
/** 更新 API 错误日志的处理状态 */
export function updateApiErrorLogStatus(id: number, processStatus: number) {
return http.put<boolean>(`/infra/api-error-log/update-status?id=${id}&processStatus=${processStatus}`)
}

View File

@@ -0,0 +1,45 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 参数配置信息 */
export interface Config {
id?: number
category: string
name: string
key: string
value: string
type: number
visible: boolean
remark: string
createTime?: Date
}
/** 获取参数配置分页列表 */
export function getConfigPage(params: PageParam) {
return http.get<PageResult<Config>>('/infra/config/page', params)
}
/** 获取参数配置详情 */
export function getConfig(id: number) {
return http.get<Config>(`/infra/config/get?id=${id}`)
}
/** 根据参数键名查询参数值 */
export function getConfigKey(configKey: string) {
return http.get<string>(`/infra/config/get-value-by-key?key=${configKey}`)
}
/** 创建参数配置 */
export function createConfig(data: Config) {
return http.post<number>('/infra/config/create', data)
}
/** 更新参数配置 */
export function updateConfig(data: Config) {
return http.put<boolean>('/infra/config/update', data)
}
/** 删除参数配置 */
export function deleteConfig(id: number) {
return http.delete<boolean>(`/infra/config/delete?id=${id}`)
}

View File

@@ -0,0 +1,36 @@
import { http } from '@/http/http'
/** 数据源配置信息 */
export interface DataSourceConfig {
id?: number
name: string
url: string
username: string
password: string
createTime?: Date
}
/** 获取数据源配置列表(无分页) */
export function getDataSourceConfigList() {
return http.get<DataSourceConfig[]>('/infra/data-source-config/list')
}
/** 获取数据源配置详情 */
export function getDataSourceConfig(id: number) {
return http.get<DataSourceConfig>(`/infra/data-source-config/get?id=${id}`)
}
/** 创建数据源配置 */
export function createDataSourceConfig(data: DataSourceConfig) {
return http.post<number>('/infra/data-source-config/create', data)
}
/** 更新数据源配置 */
export function updateDataSourceConfig(data: DataSourceConfig) {
return http.put<boolean>('/infra/data-source-config/update', data)
}
/** 删除数据源配置 */
export function deleteDataSourceConfig(id: number) {
return http.delete<boolean>(`/infra/data-source-config/delete?id=${id}`)
}

View File

@@ -0,0 +1,67 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 文件客户端配置 */
export interface FileClientConfig {
basePath?: string
host?: string
port?: number
username?: string
password?: string
mode?: string
endpoint?: string
bucket?: string
accessKey?: string
accessSecret?: string
enablePathStyleAccess?: boolean
enablePublicAccess?: boolean
region?: string
domain?: string
}
/** 文件配置信息 */
export interface FileConfig {
id?: number
name: string
storage?: number
master?: boolean
visible?: boolean
config?: FileClientConfig
remark?: string
createTime?: Date
}
/** 查询文件配置分页列表 */
export function getFileConfigPage(params: PageParam) {
return http.get<PageResult<FileConfig>>('/infra/file-config/page', params)
}
/** 查询文件配置详情 */
export function getFileConfig(id: number) {
return http.get<FileConfig>(`/infra/file-config/get?id=${id}`)
}
/** 新增文件配置 */
export function createFileConfig(data: FileConfig) {
return http.post<number>('/infra/file-config/create', data)
}
/** 修改文件配置 */
export function updateFileConfig(data: FileConfig) {
return http.put<boolean>('/infra/file-config/update', data)
}
/** 删除文件配置 */
export function deleteFileConfig(id: number) {
return http.delete<boolean>(`/infra/file-config/delete?id=${id}`)
}
/** 更新文件配置为主配置 */
export function updateFileConfigMaster(id: number) {
return http.put<boolean>(`/infra/file-config/update-master?id=${id}`)
}
/** 测试文件配置 */
export function testFileConfig(id: number) {
return http.get<string>(`/infra/file-config/test?id=${id}`)
}

View File

@@ -0,0 +1,97 @@
import { useToast } from 'wot-design-uni'
import { http } from '@/http/http'
import { useTokenStore } from '@/store/token'
import { useUserStore } from '@/store/user'
/** 文件信息 */
export interface FileVO {
id?: number
configId?: number
path: string
name?: string
url?: string
size?: number
type?: string
createTime?: Date
}
/** 文件预签名信息 */
export interface FilePresignedUrlRespVO {
configId: number // 配置编号
uploadUrl: string // 文件上传 URL
url: string // 文件访问 URL
path: string // 文件路径
}
/** 创建文件请求 */
export interface FileCreateReqVO {
configId: number
url: string
path: string
name: string
type?: string
size?: number
}
/** 获取文件预签名地址 */
export function getFilePresignedUrl(name: string, directory?: string) {
return http.get<FilePresignedUrlRespVO>('/infra/file/presigned-url', { name, directory })
}
/** 创建文件记录 */
export function createFile(data: FileCreateReqVO) {
return http.post<string>('/infra/file/create', data)
}
/** 获取文件详情 */
export function getFile(id: number) {
return http.get<FileVO>(`/infra/file/get?id=${id}`)
}
/** 删除文件 */
export function deleteFile(id: number) {
return http.delete(`/infra/file/delete?id=${id}`)
}
/**
* 上传文件到后端
*
* @param filePath 本地文件路径
* @param directory 目录(可选)
* @returns 文件访问 URL
*/
export function uploadFile(filePath: string, directory?: string): Promise<string> {
const tokenStore = useTokenStore()
const userStore = useUserStore()
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${import.meta.env.VITE_SERVER_BASEURL}/infra/file/upload`,
filePath,
name: 'file',
header: {
'Accept': '*/*',
'tenant-id': userStore.tenantId,
'Authorization': `Bearer ${tokenStore.validToken}`,
},
formData: directory ? { directory } : undefined,
success: (res) => {
if (res.statusCode === 200) {
const result = JSON.parse(res.data)
if (result.code === 0) {
resolve(result.data)
} else {
const toast = useToast()
toast.show(result.msg || '上传失败')
reject(new Error(result.msg || '上传失败'))
}
} else {
reject(new Error('上传失败'))
}
},
fail: (err) => {
console.error('上传失败:', err)
reject(err)
},
})
})
}

View File

@@ -0,0 +1,60 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
// TODO @AI不用 baseUrl 方式
const baseUrl = '/infra/job'
/** 定时任务信息 */
export interface Job {
id?: number
name: string
status: number
handlerName: string
handlerParam: string
cronExpression: string
retryCount: number
retryInterval: number
monitorTimeout: number
createTime?: Date
nextTimes?: Date[]
}
/** 获取定时任务分页列表 */
export function getJobPage(params: PageParam) {
return http.get<PageResult<Job>>(`${baseUrl}/page`, params)
}
/** 获取定时任务详情 */
export function getJob(id: number) {
return http.get<Job>(`${baseUrl}/get?id=${id}`)
}
/** 创建定时任务 */
export function createJob(data: Job) {
return http.post<number>(`${baseUrl}/create`, data)
}
/** 更新定时任务 */
export function updateJob(data: Job) {
return http.put<boolean>(`${baseUrl}/update`, data)
}
/** 删除定时任务 */
export function deleteJob(id: number) {
return http.delete<boolean>(`${baseUrl}/delete?id=${id}`)
}
/** 更新定时任务状态 */
export function updateJobStatus(id: number, status: number) {
return http.put<boolean>(`${baseUrl}/update-status`, { id, status })
}
/** 立即执行一次定时任务 */
export function runJob(id: number) {
return http.put<boolean>(`${baseUrl}/trigger?id=${id}`)
}
/** 获取定时任务的下 n 次执行时间 */
export function getJobNextTimes(id: number) {
return http.get<Date[]>(`${baseUrl}/get_next_times?id=${id}`)
}

View File

@@ -0,0 +1,31 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
// TODO @AI不用 baseUrl 方式
const baseUrl = '/infra/job-log'
/** 定时任务日志信息 */
export interface JobLog {
id?: number
jobId: number
handlerName: string
handlerParam: string
cronExpression: string
executeIndex: string
beginTime: Date
endTime: Date
duration: string
status: number
createTime?: string
result: string
}
/** 获取定时任务日志分页列表 */
export function getJobLogPage(params: PageParam) {
return http.get<PageResult<JobLog>>(`${baseUrl}/page`, params)
}
/** 获取定时任务日志详情 */
export function getJobLog(id: number) {
return http.get<JobLog>(`${baseUrl}/get?id=${id}`)
}

148
src/api/login.ts Normal file
View File

@@ -0,0 +1,148 @@
import type {
AuthPermissionInfo,
IAuthLoginRes,
ICaptcha,
IDoubleTokenRes,
} from './types/login'
import { http } from '@/http/http'
/**
* 登录表单
*/
export interface ILoginForm {
type: 'username' | 'register' | 'sms'
username?: string
password?: string
nickname?: string
captchaVerification?: string
mobile?: string
code?: string
}
/** 账号密码登录 Request VO */
export interface AuthLoginReqVO {
password?: string
username?: string
captchaVerification?: string
// 绑定社交登录时,需要传递如下参数
socialType?: number
socialCode?: string
socialState?: string
}
/** 注册 Request VO */
export interface AuthRegisterReqVO {
username: string
password: string
captchaVerification: string
}
/** 短信登录 Request VO */
export interface AuthSmsLoginReqVO {
mobile: string
code: string
}
/** 发送短信验证码 Request VO */
export interface AuthSmsSendReqVO {
mobile: string
scene: number
}
/** 租户信息 */
export interface TenantVO {
id: number
name: string
}
/** 重置密码 Request VO */
export interface AuthResetPasswordReqVO {
mobile: string
code: string
password: string
}
/** 获取验证码 */
export function getCode(data: any) {
return http.post<ICaptcha>('/system/captcha/get', data, null, null, { original: true })
}
/** 校验验证码 */
export function checkCaptcha(data: any) {
return http.post<boolean>('/system/captcha/check', data, null, null, { original: true })
}
/** 使用账号密码登录 */
export function login(data: AuthLoginReqVO) {
return http.post<IAuthLoginRes>('/system/auth/login', data)
}
/** 注册用户 */
export function register(data: AuthRegisterReqVO) {
return http.post<IAuthLoginRes>('/system/auth/register', data)
}
/** 短信登录 */
export function smsLogin(data: AuthSmsLoginReqVO) {
return http.post<IAuthLoginRes>('/system/auth/sms-login', data)
}
/** 发送短信验证码 */
export function sendSmsCode(data: AuthSmsSendReqVO) {
return http.post<boolean>('/system/auth/send-sms-code', data)
}
/** 获取租户简单列表 */
export function getTenantSimpleList() {
return http.get<TenantVO[]>('/system/tenant/simple-list')
}
/** 根据租户域名获取租户信息 */
export function getTenantByWebsite(website: string) {
return http.get<TenantVO>(`/system/tenant/get-by-website?website=${website}`)
}
/** 通过短信重置密码 */
export function smsResetPassword(data: AuthResetPasswordReqVO) {
return http.post<boolean>('/system/auth/reset-password', data)
}
/** 刷新token */
export function refreshToken(refreshToken: string) {
return http.post<IDoubleTokenRes>(`/system/auth/refresh-token?refreshToken=${refreshToken}`)
}
/** 获取权限信息 */
export function getAuthPermissionInfo() {
return http.get<AuthPermissionInfo>('/system/auth/get-permission-info')
}
/** 退出登录 */
export function logout() {
return http.post<void>('/system/auth/logout')
}
// TODO @芋艿:三方登录
/**
* 获取微信登录凭证
* @returns Promise 包含微信登录凭证(code)
*/
export function getWxCode() {
return new Promise<UniApp.LoginRes>((resolve, reject) => {
uni.login({
provider: 'weixin',
success: res => resolve(res),
fail: err => reject(new Error(err)),
})
})
}
// TODO @芋艿:三方登录
/**
* 微信登录
* @param params 微信登录参数包含code
* @returns Promise 包含登录结果
*/
export function wxLogin(data: { code: string }) {
return http.post<IAuthLoginRes>('/auth/wxLogin', data)
}

View File

@@ -0,0 +1,19 @@
import { http } from '@/http/http'
/** 地区信息 */
export interface Area {
id: number
name: string
parentId?: number
children?: Area[]
}
/** 获得地区树 */
export function getAreaTree() {
return http.get<Area[]>('/system/area/tree')
}
/** 获得 IP 对应的地区名 */
export function getAreaByIp(ip: string) {
return http.get<string>(`/system/area/get-by-ip?ip=${ip}`)
}

View File

@@ -0,0 +1,45 @@
import { http } from '@/http/http'
/** 部门信息 */
export interface Dept {
id?: number
name: string
parentId: number
status: number
sort: number
leaderUserId?: number
phone?: string
email?: string
createTime?: Date
children?: Dept[]
}
/** 获取部门列表 */
export function getDeptList(params?: { name?: string, status?: number }) {
return http.get<Dept[]>('/system/dept/list', params)
}
/** 获取部门精简列表 */
export function getSimpleDeptList() {
return http.get<Dept[]>('/system/dept/simple-list')
}
/** 获取部门详情 */
export function getDept(id: number) {
return http.get<Dept>(`/system/dept/get?id=${id}`)
}
/** 创建部门 */
export function createDept(data: Dept) {
return http.post<number>('/system/dept/create', data)
}
/** 更新部门 */
export function updateDept(data: Dept) {
return http.put<boolean>('/system/dept/update', data)
}
/** 删除部门 */
export function deleteDept(id: number) {
return http.delete<boolean>(`/system/dept/delete?id=${id}`)
}

View File

@@ -0,0 +1,46 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 字典数据 */
export interface DictData {
id?: number
dictType: string
label: string
value: string
colorType?: string
cssClass?: string
sort?: number
status: number
remark?: string
createTime?: Date
}
/** 查询字典数据(精简)列表 */
export function getSimpleDictDataList() {
return http.get<DictData[]>('/system/dict-data/simple-list')
}
/** 查询字典数据分页列表 */
export function getDictDataPage(params: PageParam) {
return http.get<PageResult<DictData>>('/system/dict-data/page', params)
}
/** 查询字典数据详情 */
export function getDictData(id: number) {
return http.get<DictData>(`/system/dict-data/get?id=${id}`)
}
/** 新增字典数据 */
export function createDictData(data: DictData) {
return http.post<number>('/system/dict-data/create', data)
}
/** 修改字典数据 */
export function updateDictData(data: DictData) {
return http.put<boolean>('/system/dict-data/update', data)
}
/** 删除字典数据 */
export function deleteDictData(id: number) {
return http.delete<boolean>(`/system/dict-data/delete?id=${id}`)
}

View File

@@ -0,0 +1,42 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 字典类型 */
export interface DictType {
id?: number
name: string
type: string
status: number
remark?: string
createTime?: Date
}
/** 查询字典类型(精简)列表 */
export function getSimpleDictTypeList() {
return http.get<DictType[]>('/system/dict-type/list-all-simple')
}
/** 查询字典类型分页列表 */
export function getDictTypePage(params: PageParam) {
return http.get<PageResult<DictType>>('/system/dict-type/page', params)
}
/** 查询字典类型详情 */
export function getDictType(id: number) {
return http.get<DictType>(`/system/dict-type/get?id=${id}`)
}
/** 新增字典类型 */
export function createDictType(data: DictType) {
return http.post<number>('/system/dict-type/create', data)
}
/** 修改字典类型 */
export function updateDictType(data: DictType) {
return http.put<boolean>('/system/dict-type/update', data)
}
/** 删除字典类型 */
export function deleteDictType(id: number) {
return http.delete<boolean>(`/system/dict-type/delete?id=${id}`)
}

View File

@@ -0,0 +1,26 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 登录日志信息 */
export interface LoginLog {
id?: number
traceId?: string
userId?: number
userType?: number
logType?: number
username?: string
userIp?: string
userAgent?: string
result?: number
createTime?: Date
}
/** 获取登录日志分页列表 */
export function getLoginLogPage(params: PageParam) {
return http.get<PageResult<LoginLog>>('/system/login-log/page', params)
}
/** 获取登录日志详情 */
export function getLoginLog(id: number) {
return http.get<LoginLog>(`/system/login-log/get?id=${id}`)
}

View File

@@ -0,0 +1,45 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 邮箱账号信息 */
export interface MailAccount {
id?: number
mail: string
username: string
password?: string
host: string
port: number
sslEnable: boolean
starttlsEnable: boolean
createTime?: string
}
/** 获取邮箱账号分页列表 */
export function getMailAccountPage(params: PageParam) {
return http.get<PageResult<MailAccount>>('/system/mail-account/page', params)
}
/** 获取邮箱账号(精简)列表 */
export function getSimpleMailAccountList() {
return http.get<MailAccount[]>('/system/mail-account/simple-list')
}
/** 获取邮箱账号详情 */
export function getMailAccount(id: number) {
return http.get<MailAccount>(`/system/mail-account/get?id=${id}`)
}
/** 创建邮箱账号 */
export function createMailAccount(data: MailAccount) {
return http.post<number>('/system/mail-account/create', data)
}
/** 更新邮箱账号 */
export function updateMailAccount(data: MailAccount) {
return http.put<boolean>('/system/mail-account/update', data)
}
/** 删除邮箱账号 */
export function deleteMailAccount(id: number) {
return http.delete<boolean>(`/system/mail-account/delete?id=${id}`)
}

View File

@@ -0,0 +1,34 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 邮件日志信息 */
export interface MailLog {
id?: number
userId?: number
userType?: number
templateId?: number
templateCode?: string
templateTitle?: string
templateContent?: string
templateParams?: Record<string, any>
accountId?: number
fromMail?: string
toMails?: string[]
ccMails?: string[]
bccMails?: string[]
sendStatus?: number
sendTime?: string
sendMessageId?: string
sendException?: string
createTime?: string
}
/** 获取邮件日志分页列表 */
export function getMailLogPage(params: PageParam) {
return http.get<PageResult<MailLog>>('/system/mail-log/page', params)
}
/** 获取邮件日志详情 */
export function getMailLog(id: number) {
return http.get<MailLog>(`/system/mail-log/get?id=${id}`)
}

View File

@@ -0,0 +1,56 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 邮件模板信息 */
export interface MailTemplate {
id?: number
name: string
code: string
accountId?: number
nickname?: string
title: string
content: string
status: number
remark?: string
params?: string[]
createTime?: string
}
/** 发送邮件请求 */
export interface MailSendReqVO {
templateCode: string
templateParams: Record<string, any>
toMails: string[]
ccMails?: string[]
bccMails?: string[]
}
/** 获取邮件模板分页列表 */
export function getMailTemplatePage(params: PageParam) {
return http.get<PageResult<MailTemplate>>('/system/mail-template/page', params)
}
/** 获取邮件模板详情 */
export function getMailTemplate(id: number) {
return http.get<MailTemplate>(`/system/mail-template/get?id=${id}`)
}
/** 创建邮件模板 */
export function createMailTemplate(data: MailTemplate) {
return http.post<number>('/system/mail-template/create', data)
}
/** 更新邮件模板 */
export function updateMailTemplate(data: MailTemplate) {
return http.put<boolean>('/system/mail-template/update', data)
}
/** 删除邮件模板 */
export function deleteMailTemplate(id: number) {
return http.delete<boolean>(`/system/mail-template/delete?id=${id}`)
}
/** 发送邮件 */
export function sendMail(data: MailSendReqVO) {
return http.post<number>('/system/mail-template/send-mail', data)
}

View File

@@ -0,0 +1,51 @@
import { http } from '@/http/http'
/** 菜单信息 */
export interface Menu {
id?: number
name: string
permission: string
type: number
sort: number
parentId: number
path: string
icon: string
component: string
componentName?: string
status: number
visible: boolean
keepAlive: boolean
alwaysShow?: boolean
createTime?: Date
children?: Menu[]
}
/** 获取菜单列表 */
export function getMenuList(params?: { name?: string, status?: number }) {
return http.get<Menu[]>('/system/menu/list', params)
}
/** 获取菜单精简列表 */
export function getSimpleMenuList() {
return http.get<Menu[]>('/system/menu/simple-list')
}
/** 获取菜单详情 */
export function getMenu(id: number) {
return http.get<Menu>(`/system/menu/get?id=${id}`)
}
/** 创建菜单 */
export function createMenu(data: Menu) {
return http.post<number>('/system/menu/create', data)
}
/** 更新菜单 */
export function updateMenu(data: Menu) {
return http.put<boolean>('/system/menu/update', data)
}
/** 删除菜单 */
export function deleteMenu(id: number) {
return http.delete<boolean>(`/system/menu/delete?id=${id}`)
}

View File

@@ -0,0 +1,39 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
const baseUrl = '/system/notice'
/** 通知公告信息 */
export interface Notice {
id?: number
title: string
content: string
type: number
status: number
createTime?: Date
}
/** 获取通知公告分页列表 */
export function getNoticePage(params: PageParam) {
return http.get<PageResult<Notice>>(`${baseUrl}/page`, params)
}
/** 获取通知公告详情 */
export function getNotice(id: number) {
return http.get<Notice>(`${baseUrl}/get?id=${id}`)
}
/** 创建通知公告 */
export function createNotice(data: Notice) {
return http.post<number>(`${baseUrl}/create`, data)
}
/** 更新通知公告 */
export function updateNotice(data: Notice) {
return http.put<boolean>(`${baseUrl}/update`, data)
}
/** 删除通知公告 */
export function deleteNotice(id: number) {
return http.delete<boolean>(`${baseUrl}/delete?id=${id}`)
}

View File

@@ -0,0 +1,54 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 站内信消息信息 */
export interface NotifyMessage {
id: number
userId: number
userType: number
templateId: number
templateCode: string
templateNickname: string
templateContent: string
templateType: number
templateParams: string
readStatus: boolean
readTime: Date
createTime?: Date
}
/** 查询站内信消息列表 */
export function getNotifyMessagePage(params: PageParam) {
return http.get<PageResult<NotifyMessage>>('/system/notify-message/page', params)
}
/** 查询站内信消息详情 */
export function getNotifyMessage(id: number) {
return http.get<NotifyMessage>(`/system/notify-message/get`, { id })
}
/** 获取我的站内信分页 */
export function getMyNotifyMessagePage(params: PageParam) {
return http.get<PageResult<NotifyMessage>>('/system/notify-message/my-page', params)
}
/** 获取我的站内信详情 */
export function getMyNotifyMessage(id: number) {
return http.get<NotifyMessage>(`/system/notify-message/get`, { id })
}
/** 批量标记站内信已读 */
export function updateNotifyMessageRead(ids: number | number[]) {
const idsArray = Array.isArray(ids) ? ids : [ids]
return http.put<boolean>('/system/notify-message/update-read', undefined, { ids: idsArray })
}
/** 标记所有站内信为已读 */
export function updateAllNotifyMessageRead() {
return http.put<boolean>('/system/notify-message/update-all-read')
}
/** 获取当前用户的未读站内信数量 */
export function getUnreadNotifyMessageCount() {
return http.get<number>('/system/notify-message/get-unread-count')
}

View File

@@ -0,0 +1,54 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 站内信模板信息 */
export interface NotifyTemplate {
id?: number
name: string
nickname: string
code: string
content: string
type?: number
params?: string[]
status: number
remark?: string
createTime?: Date
}
/** 发送站内信请求 */
export interface NotifySendReqVO {
userId: number
userType: number
templateCode: string
templateParams: Record<string, any>
}
/** 查询站内信模板列表 */
export function getNotifyTemplatePage(params: PageParam) {
return http.get<PageResult<NotifyTemplate>>('/system/notify-template/page', params)
}
/** 查询站内信模板详情 */
export function getNotifyTemplate(id: number) {
return http.get<NotifyTemplate>(`/system/notify-template/get`, { id })
}
/** 新增站内信模板 */
export function createNotifyTemplate(data: NotifyTemplate) {
return http.post<number>('/system/notify-template/create', data)
}
/** 修改站内信模板 */
export function updateNotifyTemplate(data: NotifyTemplate) {
return http.put<boolean>('/system/notify-template/update', data)
}
/** 删除站内信模板 */
export function deleteNotifyTemplate(id: number) {
return http.delete<boolean>(`/system/notify-template/delete?id=${id}`)
}
/** 发送站内信 */
export function sendNotify(data: NotifySendReqVO) {
return http.post<number>('/system/notify-template/send-notify', data)
}

View File

@@ -0,0 +1,48 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** OAuth2.0 客户端信息 */
export interface OAuth2Client {
id?: number
clientId: string
secret: string
name: string
logo: string
description: string
status: number
accessTokenValiditySeconds: number
refreshTokenValiditySeconds: number
redirectUris: string[]
autoApprove: boolean
authorizedGrantTypes: string[]
scopes: string[]
authorities: string[]
resourceIds: string[]
additionalInformation: string
createTime?: Date
}
/** 获取 OAuth2.0 客户端分页列表 */
export function getOAuth2ClientPage(params: PageParam) {
return http.get<PageResult<OAuth2Client>>('/system/oauth2-client/page', params)
}
/** 获取 OAuth2.0 客户端详情 */
export function getOAuth2Client(id: number) {
return http.get<OAuth2Client>(`/system/oauth2-client/get?id=${id}`)
}
/** 创建 OAuth2.0 客户端 */
export function createOAuth2Client(data: OAuth2Client) {
return http.post<number>('/system/oauth2-client/create', data)
}
/** 更新 OAuth2.0 客户端 */
export function updateOAuth2Client(data: OAuth2Client) {
return http.put<boolean>('/system/oauth2-client/update', data)
}
/** 删除 OAuth2.0 客户端 */
export function deleteOAuth2Client(id: number) {
return http.delete<boolean>(`/system/oauth2-client/delete?id=${id}`)
}

View File

@@ -0,0 +1,24 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** OAuth2.0 令牌信息 */
export interface OAuth2Token {
id?: number
accessToken: string
refreshToken: string
userId: number
userType: number
clientId: string
createTime?: Date
expiresTime?: Date
}
/** 获取 OAuth2.0 令牌分页列表 */
export function getOAuth2TokenPage(params: PageParam) {
return http.get<PageResult<OAuth2Token>>('/system/oauth2-token/page', params)
}
/** 删除 OAuth2.0 令牌 */
export function deleteOAuth2Token(accessToken: string) {
return http.delete<boolean>(`/system/oauth2-token/delete?accessToken=${accessToken}`)
}

View File

@@ -0,0 +1,31 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 操作日志信息 */
export interface OperateLog {
id?: number
traceId?: string
userId?: number
userType?: number
userName?: string
type?: string
subType?: string
bizId?: number
action?: string
extra?: string
requestMethod?: string
requestUrl?: string
userIp?: string
userAgent?: string
createTime?: Date
}
/** 获取操作日志分页列表 */
export function getOperateLogPage(params: PageParam) {
return http.get<PageResult<OperateLog>>('/system/operate-log/page', params)
}
/** 获取操作日志详情 */
export function getOperateLog(id: number) {
return http.get<OperateLog>(`/system/operate-log/get?id=${id}`)
}

View File

@@ -0,0 +1,43 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 岗位信息 */
export interface Post {
id?: number
name: string
code: string
sort: number
status: number
remark?: string
createTime?: string
}
/** 获取岗位分页列表 */
export function getPostPage(params: PageParam) {
return http.get<PageResult<Post>>('/system/post/page', params)
}
/** 获取岗位精简列表 */
export function getSimplePostList() {
return http.get<Post[]>('/system/post/simple-list')
}
/** 获取岗位详情 */
export function getPost(id: number) {
return http.get<Post>(`/system/post/get?id=${id}`)
}
/** 创建岗位 */
export function createPost(data: Post) {
return http.post<number>('/system/post/create', data)
}
/** 更新岗位 */
export function updatePost(data: Post) {
return http.put<boolean>('/system/post/update', data)
}
/** 删除岗位 */
export function deletePost(id: number) {
return http.delete<boolean>(`/system/post/delete?id=${id}`)
}

View File

@@ -0,0 +1,46 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 角色信息 */
export interface Role {
id: number
name: string
code: string
sort: number
status: number
type?: number
remark?: string
dataScope?: number
dataScopeDeptIds?: number[]
createTime?: Date
}
/** 获取角色分页列表 */
export function getRolePage(params: PageParam) {
return http.get<PageResult<Role>>('/system/role/page', params)
}
/** 获取角色详情 */
export function getRole(id: number) {
return http.get<Role>(`/system/role/get?id=${id}`)
}
/** 创建角色 */
export function createRole(data: Role) {
return http.post<number>('/system/role/create', data)
}
/** 更新角色 */
export function updateRole(data: Role) {
return http.put<boolean>('/system/role/update', data)
}
/** 删除角色 */
export function deleteRole(id: number) {
return http.delete<boolean>(`/system/role/delete?id=${id}`)
}
/** 获取角色精简列表 */
export function getSimpleRoleList() {
return http.get<Role[]>('/system/role/simple-list')
}

View File

@@ -0,0 +1,45 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 短信渠道信息 */
export interface SmsChannel {
id?: number
code: string
status: number
signature: string
remark?: string
apiKey: string
apiSecret?: string
callbackUrl?: string
createTime?: Date
}
/** 获取短信渠道分页列表 */
export function getSmsChannelPage(params: PageParam) {
return http.get<PageResult<SmsChannel>>('/system/sms-channel/page', params)
}
/** 获取短信渠道精简列表 */
export function getSimpleSmsChannelList() {
return http.get<SmsChannel[]>('/system/sms-channel/simple-list')
}
/** 获取短信渠道详情 */
export function getSmsChannel(id: number) {
return http.get<SmsChannel>(`/system/sms-channel/get?id=${id}`)
}
/** 创建短信渠道 */
export function createSmsChannel(data: SmsChannel) {
return http.post<number>('/system/sms-channel/create', data)
}
/** 更新短信渠道 */
export function updateSmsChannel(data: SmsChannel) {
return http.put<boolean>('/system/sms-channel/update', data)
}
/** 删除短信渠道 */
export function deleteSmsChannel(id: number) {
return http.delete<boolean>(`/system/sms-channel/delete?id=${id}`)
}

View File

@@ -0,0 +1,39 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 短信日志信息 */
export interface SmsLog {
id?: number
channelId?: number
channelCode: string
templateId?: number
templateCode: string
templateType?: number
templateContent: string
templateParams?: Record<string, any>
apiTemplateId: string
mobile: string
userId?: number
userType?: number
sendStatus?: number
sendTime?: string
apiSendCode?: string
apiSendMsg?: string
apiRequestId?: string
apiSerialNo?: string
receiveStatus?: number
receiveTime?: string
apiReceiveCode?: string
apiReceiveMsg?: string
createTime?: string
}
/** 获取短信日志分页列表 */
export function getSmsLogPage(params: PageParam) {
return http.get<PageResult<SmsLog>>('/system/sms-log/page', params)
}
/** 获取短信日志详情 */
export function getSmsLog(id: number) {
return http.get<SmsLog>(`/system/sms-log/get?id=${id}`)
}

View File

@@ -0,0 +1,55 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 短信模板信息 */
export interface SmsTemplate {
id?: number
type?: number
status: number
code: string
name: string
content: string
remark?: string
apiTemplateId: string
channelId?: number
channelCode?: string
params?: string[]
createTime?: Date
}
/** 发送短信请求 */
export interface SmsSendReqVO {
mobile: string
templateCode: string
templateParams: Record<string, any>
}
/** 获取短信模板分页列表 */
export function getSmsTemplatePage(params: PageParam) {
return http.get<PageResult<SmsTemplate>>('/system/sms-template/page', params)
}
/** 获取短信模板详情 */
export function getSmsTemplate(id: number) {
return http.get<SmsTemplate>(`/system/sms-template/get?id=${id}`)
}
/** 创建短信模板 */
export function createSmsTemplate(data: SmsTemplate) {
return http.post<number>('/system/sms-template/create', data)
}
/** 更新短信模板 */
export function updateSmsTemplate(data: SmsTemplate) {
return http.put<boolean>('/system/sms-template/update', data)
}
/** 删除短信模板 */
export function deleteSmsTemplate(id: number) {
return http.delete<boolean>(`/system/sms-template/delete?id=${id}`)
}
/** 发送短信 */
export function sendSms(data: SmsSendReqVO) {
return http.post<number>('/system/sms-template/send-sms', data)
}

View File

@@ -0,0 +1,41 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 社交客户端信息 */
export interface SocialClient {
id?: number
name: string
socialType: number
userType: number
clientId: string
clientSecret: string
agentId?: string
publicKey?: string
status: number
createTime?: Date
}
/** 获取社交客户端分页列表 */
export function getSocialClientPage(params: PageParam) {
return http.get<PageResult<SocialClient>>('/system/social-client/page', params)
}
/** 获取社交客户端详情 */
export function getSocialClient(id: number) {
return http.get<SocialClient>(`/system/social-client/get?id=${id}`)
}
/** 创建社交客户端 */
export function createSocialClient(data: SocialClient) {
return http.post<number>('/system/social-client/create', data)
}
/** 更新社交客户端 */
export function updateSocialClient(data: SocialClient) {
return http.put<boolean>('/system/social-client/update', data)
}
/** 删除社交客户端 */
export function deleteSocialClient(id: number) {
return http.delete<boolean>(`/system/social-client/delete?id=${id}`)
}

View File

@@ -0,0 +1,28 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 社交用户信息 */
export interface SocialUser {
id?: number
type: number
openid: string
token: string
rawTokenInfo: string
nickname: string
avatar: string
rawUserInfo: string
code: string
state: string
createTime?: Date
updateTime?: Date
}
/** 获取社交用户分页列表 */
export function getSocialUserPage(params: PageParam) {
return http.get<PageResult<SocialUser>>('/system/social-user/page', params)
}
/** 获取社交用户详情 */
export function getSocialUser(id: number) {
return http.get<SocialUser>(`/system/social-user/get?id=${id}`)
}

View File

@@ -0,0 +1,46 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 租户信息 */
export interface Tenant {
id?: number
name: string
packageId: number
contactName: string
contactMobile: string
accountCount: number
expireTime: Date | any
websites: string[]
status: number
createTime?: Date
}
/** 获取租户分页列表 */
export function getTenantPage(params: PageParam) {
return http.get<PageResult<Tenant>>('/system/tenant/page', params)
}
/** 获取租户精简信息列表 */
export function getSimpleTenantList() {
return http.get<Tenant[]>('/system/tenant/simple-list')
}
/** 获取租户详情 */
export function getTenant(id: number) {
return http.get<Tenant>(`/system/tenant/get?id=${id}`)
}
/** 创建租户 */
export function createTenant(data: Tenant) {
return http.post<number>('/system/tenant/create', data)
}
/** 更新租户 */
export function updateTenant(data: Tenant) {
return http.put<boolean>('/system/tenant/update', data)
}
/** 删除租户 */
export function deleteTenant(id: number) {
return http.delete<boolean>(`/system/tenant/delete?id=${id}`)
}

View File

@@ -0,0 +1,42 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 租户套餐信息 */
export interface TenantPackage {
id?: number
name: string
status: number
remark: string
menuIds: number[]
createTime?: Date
}
/** 获取租户套餐分页列表 */
export function getTenantPackagePage(params: PageParam) {
return http.get<PageResult<TenantPackage>>('/system/tenant-package/page', params)
}
/** 获取租户套餐精简信息列表 */
export function getTenantPackageList() {
return http.get<TenantPackage[]>('/system/tenant-package/get-simple-list')
}
/** 获取租户套餐详情 */
export function getTenantPackage(id: number) {
return http.get<TenantPackage>(`/system/tenant-package/get?id=${id}`)
}
/** 创建租户套餐 */
export function createTenantPackage(data: TenantPackage) {
return http.post<number>('/system/tenant-package/create', data)
}
/** 更新租户套餐 */
export function updateTenantPackage(data: TenantPackage) {
return http.put<boolean>('/system/tenant-package/update', data)
}
/** 删除租户套餐 */
export function deleteTenantPackage(id: number) {
return http.delete<boolean>(`/system/tenant-package/delete?id=${id}`)
}

View File

@@ -0,0 +1,72 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 用户信息 */
export interface User {
id?: number
username: string
nickname: string
password?: string
deptId?: number
deptName?: string
postIds?: number[]
email?: string
mobile?: string
sex?: number
avatar?: string
status: number
remark?: string
loginIp?: string
loginDate?: string
createTime?: string
}
/** 获取用户分页列表 */
export function getUserPage(params: PageParam) {
return http.get<PageResult<User>>('/system/user/page', params)
}
/** 获取用户详情 */
export function getUser(id: number) {
return http.get<User>(`/system/user/get?id=${id}`)
}
/** 创建用户 */
export function createUser(data: User) {
return http.post<number>('/system/user/create', data)
}
/** 更新用户 */
export function updateUser(data: User) {
return http.put<boolean>('/system/user/update', data)
}
/** 删除用户 */
export function deleteUser(id: number) {
return http.delete<boolean>(`/system/user/delete?id=${id}`)
}
/** 重置用户密码 */
export function resetUserPassword(id: number, password: string) {
return http.put<boolean>('/system/user/update-password', { id, password })
}
/** 修改用户状态 */
export function updateUserStatus(id: number, status: number) {
return http.put<boolean>('/system/user/update-status', { id, status })
}
/** 获取用户拥有的角色列表 */
export function getUserRoleIds(userId: number) {
return http.get<number[]>(`/system/permission/list-user-roles?userId=${userId}`)
}
/** 分配用户角色 */
export function assignUserRole(userId: number, roleIds: number[]) {
return http.post<boolean>('/system/permission/assign-user-role', { userId, roleIds })
}
/** 获取用户精简列表 */
export function getSimpleUserList() {
return http.get<User[]>('/system/user/simple-list')
}

View File

@@ -0,0 +1,48 @@
import { http } from '@/http/http'
/** 用户个人中心信息 */
export interface UserProfileVO {
id: number
username: string
nickname: string
email?: string
mobile?: string
sex?: number
avatar?: string
loginIp: string
loginDate: Date
createTime: Date
roles: { id: number, name: string }[]
dept: { id: number, name: string }
posts: { id: number, name: string }[]
}
/** 更新个人信息请求 */
export interface UpdateProfileReqVO {
nickname?: string
email?: string
mobile?: string
sex?: number
avatar?: string
}
/** 更新密码请求 */
export interface UpdatePasswordReqVO {
oldPassword: string
newPassword: string
}
/** 获取登录用户个人信息 */
export function getUserProfile() {
return http.get<UserProfileVO>('/system/user/profile/get')
}
/** 修改用户个人信息 */
export function updateUserProfile(data: UpdateProfileReqVO) {
return http.put<boolean>('/system/user/profile/update', data)
}
/** 修改用户个人密码 */
export function updateUserPassword(data: UpdatePasswordReqVO) {
return http.put<boolean>('/system/user/profile/update-password', data)
}

109
src/api/types/login.ts Normal file
View File

@@ -0,0 +1,109 @@
// 认证模式类型
export type AuthMode = 'single' | 'double'
// 单Token响应类型
export interface ISingleTokenRes {
token: string
expiresIn: number // 有效期(秒)
}
// 双Token响应类型
export interface IDoubleTokenRes {
accessToken: string
refreshToken: string
// accessExpiresIn: number // 访问令牌有效期(秒)
// refreshExpiresIn: number // 刷新令牌有效期(秒)
expiresTime: number // 访问令牌过期时间,单位:毫秒
}
/**
* 登录返回的信息,其实就是 token 信息
*/
export type IAuthLoginRes = ISingleTokenRes | IDoubleTokenRes
/**
* 用户信息
*/
export interface IUserInfoRes {
userId: number
username: string
nickname: string
avatar?: string
[key: string]: any // 允许其他扩展字段
}
/**
* 权限信息
*/
export interface AuthPermissionInfo {
user: IUserInfoRes
roles: string[]
permissions: string[]
// menus: AppRouteRecordRaw[]; // add by 芋艿:暂时用不到
}
// 认证存储数据结构
// TODO @芋艿:可以考虑删除
export interface AuthStorage {
mode: AuthMode
tokens: ISingleTokenRes | IDoubleTokenRes
userInfo?: IUserInfoRes
loginTime: number // 登录时间戳
}
/**
* 获取验证码
*/
export interface ICaptcha {
captchaEnabled: boolean
uuid: string
image: string
}
/**
* 上传成功的信息
*/
export interface IUploadSuccessInfo {
fileId: number
originalName: string
fileName: string
storagePath: string
fileHash: string
fileType: string
fileBusinessType: string
fileSize: number
}
/**
* 更新用户信息
*/
export interface IUpdateInfo {
id: number
name: string
sex: string
}
/**
* 更新用户信息
*/
export interface IUpdatePassword {
id: number
oldPassword: string
newPassword: string
confirmPassword: string
}
/**
* 判断是否为单Token响应
* @param tokenRes 登录响应数据
* @returns 是否为单Token响应
*/
export function isSingleTokenRes(tokenRes: IAuthLoginRes): tokenRes is ISingleTokenRes {
return 'token' in tokenRes && !('refreshToken' in tokenRes)
}
/**
* 判断是否为双Token响应
* @param tokenRes 登录响应数据
* @returns 是否为双Token响应
*/
export function isDoubleTokenRes(tokenRes: IAuthLoginRes): tokenRes is IDoubleTokenRes {
return 'accessToken' in tokenRes && 'refreshToken' in tokenRes
}

0
src/components/.gitkeep Normal file
View File

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { TagType } from 'wot-design-uni/components/wd-tag/types'
import { computed } from 'vue'
import { getDictObj } from '@/hooks/useDict'
interface DictTagProps {
type: string // 字典类型
value: any // 字典值
plain?: boolean // 是否镂空,默认为 true
}
const props = withDefaults(defineProps<DictTagProps>(), {
plain: true,
})
/**
* 后端颜色类型 => wd-tag 的 type 映射
*
* 后端可配置default、primary、success、info、warning、danger
* wd-tag 支持default、primary、success、warning、danger
* 匹配不上时默认用 default
*/
const COLOR_TYPE_MAP: Record<string, TagType> = {
default: 'default',
primary: 'primary',
success: 'success',
info: 'default', // wd-tag 无 info映射为 default
warning: 'warning',
danger: 'danger',
}
/** 获取字典标签 */
const dictTag = computed(() => {
// 校验参数有效性
if (!props.type || props.value === undefined || props.value === null) {
return null
}
// 获取字典对象
const dict = getDictObj(props.type, String(props.value))
if (!dict) {
return null
}
return {
label: dict.label || '',
tagType: COLOR_TYPE_MAP[dict.colorType || ''] || 'default',
cssClass: dict.cssClass,
}
})
</script>
<template>
<wd-tag
v-if="dictTag"
:type="dictTag.tagType"
:plain="plain"
:custom-class="dictTag.cssClass"
>
{{ dictTag.label }}
</wd-tag>
</template>

View File

@@ -0,0 +1 @@
export { default as DictTag } from './dict-tag.vue'

View File

@@ -0,0 +1 @@
export { default as UserPicker } from './user-picker.vue'

View File

@@ -0,0 +1,116 @@
<template>
<wd-select-picker
v-if="useDefaultSlot"
v-model="selectedId"
:label="label"
:label-width="label ? '180rpx' : '0'"
:columns="userList"
value-key="id"
label-key="nickname"
:type="type"
:prop="prop"
use-default-slot
filterable
:placeholder="placeholder"
@confirm="handleConfirm"
>
<slot />
</wd-select-picker>
<wd-select-picker
v-else
v-model="selectedId"
:label="label"
:label-width="label ? '180rpx' : '0'"
:columns="userList"
value-key="id"
label-key="nickname"
:type="type"
:prop="prop"
filterable
:placeholder="placeholder"
@confirm="handleConfirm"
/>
</template>
<script lang="ts" setup>
import type { User } from '@/api/system/user'
import { onMounted, ref, watch } from 'vue'
import { getSimpleUserList } from '@/api/system/user'
const props = withDefaults(defineProps<{
modelValue?: number | number[]
type?: 'radio' | 'checkbox'
label?: string
placeholder?: string
prop?: string
useDefaultSlot?: boolean
}>(), {
type: 'checkbox',
label: '',
placeholder: '请选择',
prop: '',
useDefaultSlot: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number | number[] | undefined): void
(e: 'confirm', users: User[]): void
}>()
const userList = ref<User[]>([])
const selectedId = ref<number | string | number[]>([])
/** 根据用户 ID 获取昵称 */
function getUserNickname(userId: number | undefined): string {
if (!userId) {
return ''
}
const user = userList.value.find(u => u.id === userId)
return user?.nickname || ''
}
defineExpose({
getUserNickname,
})
watch(
() => props.modelValue,
(val) => {
if (props.type === 'radio') {
// 单选时,如果值为 undefined使用空字符串避免警告
selectedId.value = val !== undefined ? val : ''
} else {
// 多选时,确保是数组
selectedId.value = Array.isArray(val) ? val : []
}
},
{ immediate: true },
)
/** 加载用户列表 */
async function loadUserList() {
userList.value = await getSimpleUserList()
}
/** 选择确认 */
function handleConfirm({ value }: { value: any }) {
emit('update:modelValue', value)
// 发出包含完整用户对象的 confirm 事件
if (Array.isArray(value)) {
const selectedUsers = userList.value.filter(user => value.includes(user.id))
emit('confirm', selectedUsers)
} else if (value) {
const selectedUser = userList.value.find(user => user.id === value)
emit('confirm', selectedUser ? [selectedUser] : [])
} else {
emit('confirm', [])
}
}
/** 初始化 */
onMounted(() => {
loadUserList()
})
</script>

35
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,35 @@
/// <reference types="vite/client" />
/// <reference types="vite-svg-loader" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
/** 网站标题,应用名称 */
readonly VITE_APP_TITLE: string
/** 服务端口号 */
readonly VITE_SERVER_PORT: string
/** 后台接口地址 */
readonly VITE_SERVER_BASEURL: string
/** H5是否需要代理 */
readonly VITE_APP_PROXY_ENABLE: 'true' | 'false'
/** H5是否需要代理需要的话有个前缀 */
readonly VITE_APP_PROXY_PREFIX: string
/** 后端是否有统一前缀 /api */
readonly VITE_SERVER_HAS_API_PREFIX: 'true' | 'false'
/** 认证模式,'single' | 'double' ==> 单token | 双token */
readonly VITE_AUTH_MODE: 'single' | 'double'
/** 是否清除console */
readonly VITE_DELETE_CONSOLE: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const __VITE_APP_PROXY__: 'true' | 'false'

41
src/hooks/useAccess.ts Normal file
View File

@@ -0,0 +1,41 @@
import { useUserStore } from '@/store/user'
/**
* 权限控制 Hook
* @description 提供基于角色和权限码的权限判断方法
*/
function useAccess() {
const userStore = useUserStore()
/**
* 基于角色判断是否有权限
* @description 通过用户的角色列表判断是否具有指定角色
* @param roles 需要判断的角色列表
* @returns 是否具有指定角色中的任意一个
*/
function hasAccessByRoles(roles: string[]): boolean {
const userRoleSet = new Set(userStore.roles)
const intersection = roles.filter(item => userRoleSet.has(item))
return intersection.length > 0
}
/**
* 基于权限码判断是否有权限
* @description 通过用户的权限码列表判断是否具有指定权限
* @param codes 需要判断的权限码列表
* @returns 是否具有指定权限码中的任意一个
*/
function hasAccessByCodes(codes: string[]): boolean {
const userCodesSet = new Set(userStore.permissions)
const intersection = codes.filter(item => userCodesSet.has(item))
return intersection.length > 0
}
return {
hasAccessByCodes,
hasAccessByRoles,
}
}
export { useAccess }
export default useAccess

132
src/hooks/useDict.ts Normal file
View File

@@ -0,0 +1,132 @@
import type { DictItem } from '@/store/dict'
import { useDictStore } from '@/store/dict'
type ColorType = 'error' | 'info' | 'primary' | 'success' | 'warning'
export interface DictDataType {
dictType?: string
label: string
value: boolean | number | string
colorType?: string
cssClass?: string
}
export interface NumberDictDataType extends DictDataType {
value: number
}
export interface StringDictDataType extends DictDataType {
value: string
}
/**
* 获取字典标签
*
* @param dictType 字典类型
* @param value 字典值
* @returns 字典标签
*/
export function getDictLabel(dictType: string, value: any): string {
const dictStore = useDictStore()
const dictObj = dictStore.getDictData(dictType, value)
return dictObj ? dictObj.label : ''
}
/**
* 获取字典对象
*
* @param dictType 字典类型
* @param value 字典值
* @returns 字典对象
*/
export function getDictObj(dictType: string, value: any): DictItem | null {
const dictStore = useDictStore()
const dictObj = dictStore.getDictData(dictType, value)
return dictObj || null
}
export function getIntDictOptions(dictType: string): NumberDictDataType[] {
// 获得通用的 DictDataType 列表
const dictOptions: DictDataType[] = getDictOptions(dictType)
// 转换成 number 类型的 NumberDictDataType 类型
// why 需要特殊转换:避免 IDEA 在 v-for="dict in getIntDictOptions(...)" 时el-option 的 key 会告警
const dictOption: NumberDictDataType[] = []
dictOptions.forEach((dict: DictDataType) => {
dictOption.push({
...dict,
value: Number.parseInt(`${dict.value}`),
})
})
return dictOption
}
export function getStrDictOptions(dictType: string) {
// 获得通用的 DictDataType 列表
const dictOptions: DictDataType[] = getDictOptions(dictType)
// 转换成 string 类型的 StringDictDataType 类型
// why 需要特殊转换:避免 IDEA 在 v-for="dict in getStrDictOptions(...)" 时el-option 的 key 会告警
const dictOption: StringDictDataType[] = []
dictOptions.forEach((dict: DictDataType) => {
dictOption.push({
...dict,
value: `${dict.value}`,
})
})
return dictOption
}
export function getBoolDictOptions(dictType: string) {
const dictOption: DictDataType[] = []
const dictOptions: DictDataType[] = getDictOptions(dictType)
dictOptions.forEach((dict: DictDataType) => {
dictOption.push({
...dict,
value: `${dict.value}` === 'true',
})
})
return dictOption
}
/**
* 获取字典数组,用于 picker、radio 等
*
* @param dictType 字典类型
* @param valueType 字典值类型,默认 string 类型
* @returns 字典数组
*/
export function getDictOptions(
dictType: string,
valueType: 'boolean' | 'number' | 'string' = 'string',
): DictDataType[] {
const dictStore = useDictStore()
const dictOpts = dictStore.getDictOptions(dictType)
const dictOptions: DictDataType[] = []
if (dictOpts.length > 0) {
let dictValue: boolean | number | string = ''
dictOpts.forEach((dict) => {
switch (valueType) {
case 'boolean': {
dictValue = `${dict.value}` === 'true'
break
}
case 'number': {
dictValue = Number.parseInt(`${dict.value}`)
break
}
case 'string': {
dictValue = `${dict.value}`
break
}
}
dictOptions.push({
value: dictValue,
label: dict.label,
colorType: dict.colorType as ColorType,
cssClass: dict.cssClass,
})
})
}
return dictOptions
}

54
src/hooks/useRequest.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { Ref } from 'vue'
import { ref } from 'vue'
interface IUseRequestOptions<T> {
/** 是否立即执行 */
immediate?: boolean
/** 初始化数据 */
initialData?: T
}
interface IUseRequestReturn<T, P = undefined> {
loading: Ref<boolean>
error: Ref<boolean | Error>
data: Ref<T | undefined>
run: (args?: P) => Promise<T | undefined>
}
/**
* useRequest是一个定制化的请求钩子用于处理异步请求和响应。
* @param func 一个执行异步请求的函数返回一个包含响应数据的Promise。
* @param options 包含请求选项的对象 {immediate, initialData}。
* @param options.immediate 是否立即执行请求默认为false。
* @param options.initialData 初始化数据默认为undefined。
* @returns 返回一个对象{loading, error, data, run},包含请求的加载状态、错误信息、响应数据和手动触发请求的函数。
*/
export default function useRequest<T, P = undefined>(
func: (args?: P) => Promise<T>,
options: IUseRequestOptions<T> = { immediate: false },
): IUseRequestReturn<T, P> {
const loading = ref(false)
const error = ref(false)
const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>
const run = async (args?: P) => {
loading.value = true
return func(args)
.then((res) => {
data.value = res
error.value = false
return data.value
})
.catch((err) => {
error.value = err
throw err
})
.finally(() => {
loading.value = false
})
}
if (options.immediate) {
(run as (args: P) => Promise<T | undefined>)({} as P)
}
return { loading, error, data, run }
}

116
src/hooks/useScroll.md Normal file
View File

@@ -0,0 +1,116 @@
# 上拉刷新和下拉加载更多
在 unibest 框架中,我们通过组合 `useScroll` Hook 可结合 `scroll-view` 组件来轻松实现上拉刷新和下拉加载更多的功能。
场景一 页面滚动
```
definePage({
style: {
navigationBarTitleText: '上拉刷新和下拉加载更多',
enablePullDownRefresh: true,
onReachBottomDistance: 100,
},
})
```
场景二 局部滚动 结合 `scroll-view`
## 关键文件
- `src/hooks/useScroll.ts`: 提供了核心的滚动逻辑处理 Hook。
- `src/pages-sub/demo/scroll.vue`: 一个具体的实现示例页面。
## `useScroll` Hook
`useScroll` 是一个 Vue Composition API Hook它封装了处理下拉刷新和上拉加载的通用逻辑。
### 主要功能
- **管理加载状态**: 自动处理 `loading`(加载中)、`finished`(已加载全部)和 `error`(加载失败)等状态。
- **分页逻辑**: 内部维护分页参数(页码 `page` 和每页数量 `pageSize`)。
- **事件处理**: 提供 `onScrollToLower`(滚动到底部)、`onRefresherRefresh`(下拉刷新)等方法,用于在视图层触发。
- **数据合并**: 自动将新加载的数据追加到现有列表 `list` 中。
### 使用方法
```typescript
import { useScroll } from '@/hooks/useScroll'
import { getList } from '@/service/list' // 你的数据请求API
const {
list, // 响应式的数据列表
loading, // 是否加载中
finished, // 是否已全部加载
error, // 是否加载失败
onScrollToLower, // 滚动到底部时触发的事件
onRefresherRefresh, // 下拉刷新时触发的事件
} = useScroll(getList) // 将获取数据的API函数传入
```
## `scroll-view` 组件
`scroll-view` 是 uni-app 提供的可滚动视图区域组件,它提供了一系列属性来支持下拉刷新和上拉加载。
### 关键属性
- `scroll-y`: 允许纵向滚动。
- `refresher-enabled`: 启用下拉刷新。
- `refresher-triggered`: 控制下拉刷新动画的显示与隐藏,通过 `loading` 状态绑定。
- `@scrolltolower`: 滚动到底部时触发的事件,绑定 `onScrollToLower` 方法。
- `@refresherrefresh`: 触发下拉刷新时触发的事件,绑定 `onRefresherRefresh` 方法。
## 示例代码
以下是 `src/pages-sub/demo/scroll.vue` 中的核心代码,展示了如何将 `useScroll``scroll-view` 结合使用。
```vue
<template>
<view class="scroll-page">
<scroll-view
class="scroll-view"
scroll-y
:refresher-enabled="true"
:refresher-triggered="loading"
@scrolltolower="onScrollToLower"
@refresherrefresh="onRefresherRefresh"
>
<view v-for="item in list" :key="item.id" class="scroll-item">
{{ item.name }}
</view>
<!-- 加载状态提示 -->
<view v-if="loading" class="loading-tip">加载中...</view>
<view v-if="finished" class="finished-tip">没有更多了</view>
<view v-if="error" class="error-tip">加载失败请重试</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { useScroll } from '@/hooks/useScroll'
import { getList } from '@/service/list'
const { list, loading, finished, error, onScrollToLower, onRefresherRefresh } = useScroll(getList)
</script>
<style scoped>
/* 样式省略 */
.scroll-page, .scroll-view {
height: 100%;
}
</style>
```
## 实现步骤总结
1. **创建API**: 确保你有一个返回分页数据的API请求函数例如 `getList`),它应该接受页码和页面大小作为参数。
2. **调用 `useScroll`**: 在你的页面脚本中,导入并调用 `useScroll` Hook将你的API函数作为参数传入。
3. **模板绑定**:
- 使用 `scroll-view` 组件作为滚动容器。
- 将其 `refresher-triggered` 属性绑定到 `useScroll` 返回的 `loading` 状态。
- 将其 `@scrolltolower` 事件绑定到 `onScrollToLower` 方法。
- 将其 `@refresherrefresh` 事件绑定到 `onRefresherRefresh` 方法。
4. **渲染列表**: 使用 `v-for` 指令渲染 `useScroll` 返回的 `list` 数组。
5. **添加加载提示**: 根据 `loading`, `finished`, `error` 状态,在列表底部显示不同的提示信息,提升用户体验。
通过以上步骤,你就可以在项目中快速集成一个功能完善、体验良好的上拉刷新和下拉加载列表。

75
src/hooks/useScroll.ts Normal file
View File

@@ -0,0 +1,75 @@
/* eslint-disable brace-style */ // 原因unibest 官方维护的代码,尽量不要大概,避免难以合并
import type { Ref } from 'vue'
import { onMounted, ref } from 'vue'
interface UseScrollOptions<T> {
fetchData: (page: number, pageSize: number) => Promise<T[]>
pageSize?: number
}
interface UseScrollReturn<T> {
list: Ref<T[]>
loading: Ref<boolean>
finished: Ref<boolean>
error: Ref<any>
refresh: () => Promise<void>
loadMore: () => Promise<void>
}
export function useScroll<T>({
fetchData,
pageSize = 10,
}: UseScrollOptions<T>): UseScrollReturn<T> {
const list = ref<T[]>([]) as Ref<T[]>
const loading = ref(false)
const finished = ref(false)
const error = ref<any>(null)
const page = ref(1)
const loadData = async () => {
if (loading.value || finished.value)
return
loading.value = true
error.value = null
try {
const data = await fetchData(page.value, pageSize)
if (data.length < pageSize) {
finished.value = true
}
list.value.push(...data)
page.value++
}
catch (err) {
error.value = err
}
finally {
loading.value = false
}
}
const refresh = async () => {
page.value = 1
finished.value = false
list.value = []
await loadData()
}
const loadMore = async () => {
await loadData()
}
onMounted(() => {
refresh()
})
return {
list,
loading,
finished,
error,
refresh,
loadMore,
}
}

172
src/hooks/useUpload.ts Normal file
View File

@@ -0,0 +1,172 @@
import { ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getEnvBaseUrl } from '@/utils/index'
const VITE_UPLOAD_BASEURL = `${getEnvBaseUrl()}/upload`
type TfileType = 'image' | 'file'
type TImage = 'png' | 'jpg' | 'jpeg' | 'webp' | '*'
type TFile = 'doc' | 'docx' | 'ppt' | 'zip' | 'xls' | 'xlsx' | 'txt' | TImage
interface TOptions<T extends TfileType> {
formData?: Record<string, any>
maxSize?: number
accept?: T extends 'image' ? TImage[] : TFile[]
fileType?: T
success?: (params: any) => void
error?: (err: any) => void
}
export default function useUpload<T extends TfileType>(options: TOptions<T> = {} as TOptions<T>) {
const {
formData = {},
maxSize = 5 * 1024 * 1024,
accept = ['*'],
fileType = 'image',
success,
error: onError,
} = options
const loading = ref(false)
const error = ref<Error | null>(null)
const data = ref<any>(null)
const toast = useToast()
const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string, size: number }) => {
if (size > maxSize) {
// 注释 by 芋艿:使用 wd-toast 替代
// uni.showToast({
// title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`,
// icon: 'none',
// })
toast.show(`文件大小不能超过 ${maxSize / 1024 / 1024}MB`)
return
}
// const fileExtension = file?.tempFiles?.name?.split('.').pop()?.toLowerCase()
// const isTypeValid = accept.some((type) => type === '*' || type.toLowerCase() === fileExtension)
// if (!isTypeValid) {
// uni.showToast({
// title: `仅支持 ${accept.join(', ')} 格式的文件`,
// icon: 'none',
// })
// return
// }
loading.value = true
uploadFile({
tempFilePath,
formData,
onSuccess: (res) => {
// 修改这里的解析逻辑,适应不同平台的返回格式
let parsedData = res
try {
// 尝试解析为JSON
const jsonData = JSON.parse(res)
// 检查是否包含data字段
parsedData = jsonData.data || jsonData
} catch (e) {
// 如果解析失败,使用原始数据
console.log('Response is not JSON, using raw data:', res)
}
data.value = parsedData
// console.log('上传成功', res)
success?.(parsedData)
},
onError: (err) => {
error.value = err
onError?.(err)
},
onComplete: () => {
loading.value = false
},
})
}
const run = () => {
// 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
// 微信小程序在2023年10月17日之后使用本API需要配置隐私协议
const chooseFileOptions = {
count: 1,
success: (res: any) => {
console.log('File selected successfully:', res)
// 小程序中res:{errMsg: "chooseImage:ok", tempFiles: [{fileType: "image", size: 48976, tempFilePath: "http://tmp/5iG1WpIxTaJf3ece38692a337dc06df7eb69ecb49c6b.jpeg"}]}
// h5中res:{errMsg: "chooseImage:ok", tempFilePaths: "blob:http://localhost:9000/f74ab6b8-a14d-4cb6-a10d-fcf4511a0de5", tempFiles: [File]}
// h5的File有以下字段{name: "girl.jpeg", size: 48976, type: "image/jpeg"}
// App中res:{errMsg: "chooseImage:ok", tempFilePaths: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", tempFiles: [File]}
// App的File有以下字段{path: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", size: 48976}
let tempFilePath = ''
let size = 0
// #ifdef MP-WEIXIN
tempFilePath = res.tempFiles[0].tempFilePath
size = res.tempFiles[0].size
// #endif
// #ifndef MP-WEIXIN
tempFilePath = res.tempFilePaths[0]
size = res.tempFiles[0].size
// #endif
handleFileChoose({ tempFilePath, size })
},
fail: (err: any) => {
console.error('File selection failed:', err)
error.value = err
onError?.(err)
},
}
if (fileType === 'image') {
// #ifdef MP-WEIXIN
uni.chooseMedia({
...chooseFileOptions,
mediaType: ['image'],
})
// #endif
// #ifndef MP-WEIXIN
uni.chooseImage(chooseFileOptions)
// #endif
} else {
uni.chooseFile({
...chooseFileOptions,
type: 'all',
})
}
}
return { loading, error, data, run }
}
async function uploadFile({
tempFilePath,
formData,
onSuccess,
onError,
onComplete,
}: {
tempFilePath: string
formData: Record<string, any>
onSuccess: (data: any) => void
onError: (err: any) => void
onComplete: () => void
}) {
uni.uploadFile({
url: VITE_UPLOAD_BASEURL,
filePath: tempFilePath,
name: 'file',
formData,
success: (uploadFileRes) => {
try {
const data = uploadFileRes.data
onSuccess(data)
} catch (err) {
onError(err)
}
},
fail: (err) => {
console.error('Upload failed:', err)
onError(err)
},
complete: onComplete,
})
}

223
src/http/http.ts Normal file
View File

@@ -0,0 +1,223 @@
import type { IDoubleTokenRes } from '@/api/types/login'
import type { CustomRequestOptions, IResponse } from '@/http/types'
import { nextTick } from 'vue'
import { useTokenStore } from '@/store/token'
import { getLastPage, isDoubleTokenMode } from '@/utils'
import { ApiEncrypt } from '@/utils/encrypt'
import { toLoginPage } from '@/utils/toLoginPage'
import { ResultEnum } from './tools/enum'
// 刷新 token 状态管理
let refreshing = false // 防止重复刷新 token 标识
let taskQueue: (() => void)[] = [] // 刷新 token 请求队列
export function http<T>(options: CustomRequestOptions) {
// 1. 返回 Promise 对象
return new Promise<T>((resolve, reject) => {
uni.request({
...options,
dataType: 'json',
// #ifndef MP-WEIXIN
responseType: 'json',
// #endif
// 响应成功
success: async (res) => {
let responseData = res.data as IResponse<T>
// add by panda检查是否需要解密响应数据
const encryptHeader = ApiEncrypt.getEncryptHeader()
const isEncryptResponse = res.header[encryptHeader] === 'true' || res.header[encryptHeader.toLowerCase()] === 'true'
if (isEncryptResponse && typeof responseData === 'string') {
try {
// 解密响应数据
responseData = ApiEncrypt.decryptResponse(responseData)
} catch (error) {
console.error('响应数据解密失败:', error)
throw new Error(`响应数据解密失败: ${(error as Error).message}`)
}
}
const { code } = responseData
// 检查是否是401错误包括HTTP状态码401或业务码401
const isTokenExpired = res.statusCode === 401 || code === 401
if (isTokenExpired) {
const tokenStore = useTokenStore()
if (!isDoubleTokenMode) {
// 未启用双token策略清理用户信息跳转到登录页
tokenStore.logout()
toLoginPage()
return reject(res)
}
/* -------- 无感刷新 token ----------- */
const { refreshToken } = tokenStore.tokenInfo as IDoubleTokenRes || {}
// token 失效的,且有刷新 token 的,才放到请求队列里
if (refreshToken) {
taskQueue.push(() => {
resolve(http<T>(options))
})
}
// 如果有 refreshToken 且未在刷新中,发起刷新 token 请求
if (refreshToken && !refreshing) {
refreshing = true
try {
// 发起刷新 token 请求(使用 store 的 refreshToken 方法)
await tokenStore.refreshToken()
// 刷新 token 成功
refreshing = false
nextTick(() => {
// 关闭其他弹窗
// 注释 by 芋艿:刷新 token 成功,是后台静默操作,没必要提示用户
// uni.hideToast()
// uni.showToast({
// title: 'token 刷新成功',
// icon: 'none',
// })
})
// 将任务队列的所有任务重新请求
taskQueue.forEach(task => task())
} catch (refreshErr) {
console.error('刷新 token 失败:', refreshErr)
refreshing = false
// 刷新 token 失败,跳转到登录页
nextTick(() => {
// 关闭其他弹窗
uni.hideToast()
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none',
})
})
// 清除用户信息
await tokenStore.logout()
// 跳转到登录页
setTimeout(() => {
// 优化 by 芋艿:跳转登录页时,携带上次浏览的页面地址,登录成功后可以跳回去
const lastPage = getLastPage()
let queryString = ''
if (lastPage) {
const fullPath = lastPage.$page?.fullPath || `/${lastPage.route}`
queryString = `?redirect=${encodeURIComponent(fullPath)}`
}
toLoginPage({ queryString })
}, 2000)
} finally {
// 不管刷新 token 成功与否,都清空任务队列
taskQueue = []
}
}
return reject(res)
}
// 处理其他成功状态HTTP状态码200-299
if (res.statusCode >= 200 && res.statusCode < 300) {
// add by panda 25.12.10:如果设置了 original 为 true则返回原始数据。例如说滑块验证码有自己的返回格式
if (options.original) {
return resolve(responseData as unknown as T)
}
// 处理业务逻辑错误
if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) {
// add by 芋艿:后端返回的 msg 提示
!options.hideErrorToast
&& uni.showToast({
icon: 'none',
title: responseData.msg || responseData.message || '请求错误',
})
// add by 芋艿reject 替代原本的 resolve避免调用的地方以为请求成功
return reject(responseData)
}
return resolve(responseData.data)
}
// 处理其他错误
!options.hideErrorToast
&& uni.showToast({
icon: 'none',
title: (res.data as any).msg || '请求错误',
})
reject(res)
},
// 响应失败
fail(err) {
uni.showToast({
icon: 'none',
title: '网络错误,换个网络试试',
})
reject(err)
},
})
})
}
/**
* GET 请求
* @param url 后台地址
* @param query 请求query参数
* @param header 请求头默认为json格式
* @param options 其他配置项
* @returns
*/
export function httpGet<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
method: 'GET',
header,
...options,
})
}
/**
* POST 请求
* @param url 后台地址
* @param data 请求body参数
* @param query 请求query参数post请求也支持query很多微信接口都需要
* @param header 请求头默认为json格式
* @param options 其他配置项
* @returns
*/
export function httpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
data,
method: 'POST',
header,
...options,
})
}
/**
* PUT 请求
*/
export function httpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
data,
query,
method: 'PUT',
header,
...options,
})
}
/**
* DELETE 请求(无请求体,仅 query
*/
export function httpDelete<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
data,
query,
method: 'DELETE',
header,
...options,
})
}
// 支持与 axios 类似的API调用
http.get = httpGet
http.post = httpPost
http.put = httpPut
http.delete = httpDelete

104
src/http/interceptor.ts Normal file
View File

@@ -0,0 +1,104 @@
/* eslint-disable brace-style */ // 原因unibest 官方维护的代码,尽量不要大概,避免难以合并
import type { CustomRequestOptions } from '@/http/types'
import { useTokenStore, useUserStore } from '@/store'
import { getEnvBaseUrl } from '@/utils'
import { ApiEncrypt } from '@/utils/encrypt'
import { stringifyQuery } from './tools/queryString'
// 请求基准地址
const baseUrl = getEnvBaseUrl()
const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLE
const whiteList: string[] = [
'/login',
'/refresh-token',
'/system/tenant/get-id-by-name',
] // 白名单列表,不需要传递 token 字段
// 拦截器配置
const httpInterceptor = {
// 拦截前触发
invoke(options: CustomRequestOptions) {
// 接口请求支持通过 query 参数配置 queryString
if (options.query) {
const queryStr = stringifyQuery(options.query)
if (options.url.includes('?')) {
options.url += `&${queryStr}`
}
else {
options.url += `?${queryStr}`
}
}
// 非 http 开头需拼接地址
if (!options.url.startsWith('http')) {
// #ifdef H5
if (JSON.parse(import.meta.env.VITE_APP_PROXY_ENABLE)) {
// 自动拼接代理前缀
options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url
}
else {
options.url = baseUrl + options.url
}
// #endif
// 非H5正常拼接
// #ifndef H5
options.url = baseUrl + options.url
// #endif
// TIPS: 如果需要对接多个后端服务,也可以在这里处理,拼接成所需要的地址
}
// 1. 请求超时
options.timeout = 60000 // 60s
// 2. (可选)添加小程序端请求头标识
options.header = {
...options.header,
}
// 3. 添加 token 请求头标识
const tokenStore = useTokenStore()
const token = tokenStore.validToken
let isToken = (options!.header || {}).isToken === false
for (const v of whiteList) {
if (options.url && options.url.includes(v)) {
isToken = false
break
}
}
if (!isToken && token) {
options.header.Authorization = `Bearer ${token}`
}
// 4. 添加租户标识
if (tenantEnable && tenantEnable === 'true') {
const tenantId = useUserStore().tenantId
if (tenantId) {
options.header['tenant-id'] = tenantId
}
}
// 5. add by panda是否 API 加密
if (options.isEncrypt) {
try {
// 加密请求数据
if (options.data) {
options.data = ApiEncrypt.encryptRequest(options.data)
// 设置加密标识头
options.header[ApiEncrypt.getEncryptHeader()] = 'true'
}
} catch (error) {
console.error('请求数据加密失败:', error)
throw error
}
}
return options
},
}
export const requestInterceptor = {
install() {
// 拦截 request 请求
uni.addInterceptor('request', httpInterceptor)
// 拦截 uploadFile 文件上传
uni.addInterceptor('uploadFile', httpInterceptor)
},
}

68
src/http/tools/enum.ts Normal file
View File

@@ -0,0 +1,68 @@
export enum ResultEnum {
// 0和200当做成功都很普遍这里直接兼容两者PS0和200通常都不会当做错误码但是有的接口会返回0有的接口会返回200
Success0 = 0, // 成功
Success200 = 200, // 成功
Error = 400, // 错误
Unauthorized = 401, // 未授权
Forbidden = 403, // 禁止访问原为forbidden
NotFound = 404, // 未找到原为notFound
MethodNotAllowed = 405, // 方法不允许原为methodNotAllowed
RequestTimeout = 408, // 请求超时原为requestTimeout
InternalServerError = 500, // 服务器错误原为internalServerError
NotImplemented = 501, // 未实现原为notImplemented
BadGateway = 502, // 网关错误原为badGateway
ServiceUnavailable = 503, // 服务不可用原为serviceUnavailable
GatewayTimeout = 504, // 网关超时原为gatewayTimeout
HttpVersionNotSupported = 505, // HTTP版本不支持原为httpVersionNotSupported
}
export enum ContentTypeEnum {
JSON = 'application/json;charset=UTF-8',
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
FORM_DATA = 'multipart/form-data;charset=UTF-8',
}
/**
* 根据状态码,生成对应的错误信息
* @param {number|string} status 状态码
* @returns {string} 错误信息
*/
export function ShowMessage(status: number | string): string {
let message: string
switch (status) {
case 400:
message = '请求错误(400)'
break
case 401:
message = '未授权,请重新登录(401)'
break
case 403:
message = '拒绝访问(403)'
break
case 404:
message = '请求出错(404)'
break
case 408:
message = '请求超时(408)'
break
case 500:
message = '服务器错误(500)'
break
case 501:
message = '服务未实现(501)'
break
case 502:
message = '网络错误(502)'
break
case 503:
message = '服务不可用(503)'
break
case 504:
message = '网络超时(504)'
break
case 505:
message = 'HTTP版本不受支持(505)'
break
default:
message = `连接出错(${status})!`
}
return `${message},请检查网络或联系管理员!`
}

View File

@@ -0,0 +1,29 @@
/**
* 将对象序列化为URL查询字符串用于替代第三方的 qs 库,节省宝贵的体积
* 支持基本类型值和数组,不支持嵌套对象
* @param obj 要序列化的对象
* @returns 序列化后的查询字符串
*/
export function stringifyQuery(obj: Record<string, any>): string {
if (!obj || typeof obj !== 'object' || Array.isArray(obj))
return ''
return Object.entries(obj)
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
// 对键进行编码
const encodedKey = encodeURIComponent(key)
// 处理数组类型
if (Array.isArray(value)) {
return value
.filter(item => item !== undefined && item !== null)
.map(item => `${encodedKey}=${encodeURIComponent(item)}`)
.join('&')
}
// 处理基本类型
return `${encodedKey}=${encodeURIComponent(value)}`
})
.join('&')
}

41
src/http/types.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* 在 uniapp 的 RequestOptions 和 IUniUploadFileOptions 基础上,添加自定义参数
*/
export type CustomRequestOptions = UniApp.RequestOptions & {
query?: Record<string, any>
/** 出错时是否隐藏错误提示 */
hideErrorToast?: boolean
/** 是否返回原始数据 add by panda 25.12.10 */
original?: boolean
/** 是否API加密 add by panda 25.12.24 */
isEncrypt?: boolean
} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
// 通用响应格式(兼容 msg + message 字段)
export type IResponse<T = any> = {
code: number
data: T
message: string
[key: string]: any // 允许额外属性
} | {
code: number
data: T
msg: string
[key: string]: any // 允许额外属性
}
/** 分页参数 */
export interface PageParam {
pageNo: number
pageSize: number
[key: string]: any // 允许额外属性
}
/** 分页结果 */
export interface PageResult<T> {
list: T[]
total: number
}
/** 加载状态枚举 - 从 wot-design-uni 重新导出 */
export type { LoadMoreState } from 'wot-design-uni/components/wd-loadmore/types'

3
src/layouts/default.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<slot />
</template>

19
src/main.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
import { requestInterceptor } from './http/interceptor'
import { routeInterceptor } from './router/interceptor'
import store from './store'
import '@/style/index.scss'
import 'virtual:uno.css'
export function createApp() {
const app = createSSRApp(App)
app.use(store)
app.use(routeInterceptor)
app.use(requestInterceptor)
return {
app,
}
}

View File

@@ -0,0 +1,168 @@
<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-input
v-model="formData.name"
placeholder="请输入分类名"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
分类标志
</view>
<wd-input
v-model="formData.code"
placeholder="请输入分类标志"
clearable
/>
</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.COMMON_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-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({
name: undefined as string | undefined,
code: undefined as string | undefined,
status: -1, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined],
})
// 时间范围选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`分类名:${formData.name}`)
}
if (formData.code) {
conditions.push(`分类标志:${formData.code}`)
}
if (formData.status !== -1) {
conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`创建时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索流程分类'
})
/** 创建时间[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', {
...formData,
status: formData.status === -1 ? undefined : formData.status,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.code = undefined
formData.status = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,129 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程分类详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="分类编号" :value="formData?.id" />
<wd-cell title="分类名" :value="formData?.name" />
<wd-cell title="分类标志" :value="formData?.code" />
<wd-cell title="分类描述" :value="formData?.description || '-'" />
<wd-cell title="分类状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="分类排序" :value="formData?.sort" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['bpm:category:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['bpm:category:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Category } from '@/api/bpm/category'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteCategory, getCategory } from '@/api/bpm/category'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<Category>()
const deleting = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/category/index')
}
/** 加载流程分类详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getCategory(props.id)
} finally {
toast.close()
}
}
/** 编辑流程分类 */
function handleEdit() {
uni.navigateTo({
url: `/pages-bpm/category/form/index?id=${props.id}`,
})
}
/** 删除流程分类 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该流程分类吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteCategory(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,159 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="分类名"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入分类名"
/>
<wd-input
v-model="formData.code"
label="分类标志"
label-width="180rpx"
prop="code"
clearable
placeholder="请输入分类标志"
/>
<wd-textarea
v-model="formData.description"
label="分类描述"
label-width="180rpx"
prop="description"
clearable
placeholder="请输入分类描述"
/>
<wd-cell title="分类状态" title-width="180rpx" prop="status" center>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-input
v-model.number="formData.sort"
label="分类排序"
label-width="180rpx"
prop="sort"
type="number"
clearable
placeholder="请输入分类排序"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { Category } from '@/api/bpm/category'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createCategory, getCategory, updateCategory } from '@/api/bpm/category'
import { getIntDictOptions } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑流程分类' : '新增流程分类')
const formLoading = ref(false)
const formData = ref<Category>({
id: undefined,
name: '',
code: '',
status: CommonStatusEnum.ENABLE,
description: '',
sort: 0,
})
const formRules = {
name: [{ required: true, message: '分类名不能为空' }],
code: [{ required: true, message: '分类标志不能为空' }],
status: [{ required: true, message: '分类状态不能为空' }],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/category/index')
}
/** 加载流程分类详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getCategory(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateCategory(formData.value)
toast.success('修改成功')
} else {
await createCategory(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,167 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程分类管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 流程分类列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">分类标志</text>
<text>{{ item.code }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">分类描述</text>
<text class="min-w-0 flex-1 truncate">{{ item.description || '-' }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">分类排序</text>
<text>{{ item.sort }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间</text>
<text class="line-clamp-1">{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无流程分类数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['bpm:category:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { Category } from '@/api/bpm/category'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getCategoryPage } from '@/api/bpm/category'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<Category[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询流程分类列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getCategoryPage(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 handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增流程分类 */
function handleAdd() {
uni.navigateTo({
url: '/pages-bpm/category/form/index',
})
}
/** 查看详情 */
function handleDetail(item: Category) {
uni.navigateTo({
url: `/pages-bpm/category/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,168 @@
<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>

View File

@@ -0,0 +1,285 @@
<template>
<view class="yd-page-container pb-[76rpx]">
<!-- 顶部导航栏 -->
<wd-navbar
title="发起请假"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
<!-- 表单内容 -->
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border title="请假信息">
<wd-picker
v-model="formData.type"
:columns="getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)"
label="请假类型"
label-width="200rpx"
prop="type"
:rules="[{ required: true, message: '请选择请假类型' }]"
placeholder="请选择请假类型"
/>
<wd-datetime-picker
v-model="formData.startTime"
label="开始时间"
label-width="200rpx"
prop="startTime"
:rules="[{ required: true, message: '请选择开始时间' }]"
placeholder="请选择开始时间"
/>
<wd-datetime-picker
v-model="formData.endTime"
label="结束时间"
label-width="200rpx"
prop="endTime"
:rules="[{ required: true, message: '请选择结束时间' }]"
placeholder="请选择结束时间"
/>
<wd-textarea
v-model="formData.reason"
label="请假原因"
label-width="200rpx"
prop="reason"
:rules="[{ required: true, message: '请输入请假原因' }]"
placeholder="请输入请假原因"
:maxlength="200"
show-word-limit
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 流程预览卡片 -->
<view class="mx-24rpx mb-120rpx mt-24rpx rounded-16rpx bg-white">
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<text class="text-28rpx text-[#333] font-bold">流程预览</text>
<wd-loading v-if="processTimeLineLoading" size="32rpx" />
</view>
<!-- 流程时间线 -->
<ProcessInstanceTimeline
v-if="activityNodes.length > 0"
:activity-nodes="activityNodes"
:show-status-icon="false"
@select-user-confirm="selectUserConfirm"
/>
<!-- 无流程数据提示 -->
<view v-else-if="!processTimeLineLoading" class="py-40rpx text-center">
<text class="text-24rpx text-[#999]">暂无流程预览数据</text>
</view>
</view>
</view>
<!-- 底部提交按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button type="primary" class="flex-1" :loading="formLoading" @click="handleSubmit">
提交
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { Leave } from '@/api/bpm/oa/leave'
import type { ApprovalNodeInfo } from '@/api/bpm/processInstance'
import { computed, onMounted, ref, watch } from 'vue'
import { useMessage, useToast } from 'wot-design-uni'
import { getProcessDefinition } from '@/api/bpm/definition'
import { createLeave } from '@/api/bpm/oa/leave'
import { getApprovalDetail } from '@/api/bpm/processInstance'
import { getIntDictOptions } from '@/hooks/useDict'
import ProcessInstanceTimeline from '@/pages-bpm/processInstance/detail/components/time-line.vue'
import { navigateBackPlus } from '@/utils'
import { BpmCandidateStrategyEnum, BpmNodeIdEnum, DICT_TYPE } from '@/utils/constants'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const message = useMessage()
const formLoading = ref(false)
const processTimeLineLoading = ref(false) // 流程预览加载状态
// 流程相关数据
const processDefineKey = 'oa_leave' // 流程定义 Key
const processDefinitionId = ref('')
const activityNodes = ref<ApprovalNodeInfo[]>([]) // 审批节点信息
const startUserSelectTasks = ref<any[]>([]) // 发起人需要选择审批人的用户任务列表
const startUserSelectAssignees = ref<any>({}) // 发起人选择审批人的数据
const tempStartUserSelectAssignees = ref<any>({}) // 临时保存的审批人数据
const formData = ref<Partial<Leave>>({
type: undefined,
startTime: undefined,
endTime: undefined,
reason: undefined,
})
const formRules = {
type: [{ required: true, message: '请选择请假类型' }],
startTime: [{ required: true, message: '请选择开始时间' }],
endTime: [{ required: true, message: '请选择结束时间' }],
reason: [{ required: true, message: '请输入请假原因' }],
}
const formRef = ref<FormInstance>()
// 计算请假天数
const leaveDays = computed(() => {
if (!formData.value.startTime || !formData.value.endTime) {
return 0
}
const start = new Date(formData.value.startTime)
const end = new Date(formData.value.endTime)
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
})
/** 返回上一页 */
function handleBack() {
message.confirm({
title: '提示',
msg: '确定要返回吗?请先保存您填写的信息!',
}).then(({ action }) => {
if (action === 'confirm') {
navigateBackPlus('/pages-bpm/oa/leave/index')
}
})
}
/** 获取流程审批详情 */
async function getProcessApprovalDetail() {
if (!processDefinitionId.value) {
return
}
processTimeLineLoading.value = true
try {
const data = await getApprovalDetail({
processDefinitionId: processDefinitionId.value,
activityId: BpmNodeIdEnum.START_USER_NODE_ID,
processVariablesStr: JSON.stringify({
day: leaveDays.value,
}),
})
if (!data) {
toast.show('查询不到审批详情信息!')
return
}
// 获取审批节点,显示 Timeline 的数据
activityNodes.value = data.activityNodes || []
// 获取发起人自选的任务
startUserSelectTasks.value = data.activityNodes?.filter(
(node: ApprovalNodeInfo) =>
BpmCandidateStrategyEnum.START_USER_SELECT === node.candidateStrategy,
) || []
// 恢复之前的选择审批人
if (startUserSelectTasks.value.length > 0) {
for (const node of startUserSelectTasks.value) {
startUserSelectAssignees.value[node.id]
= tempStartUserSelectAssignees.value[node.id]
&& tempStartUserSelectAssignees.value[node.id].length > 0
? tempStartUserSelectAssignees.value[node.id]
: []
}
}
} catch (error) {
console.error('获取流程审批详情失败:', error)
} finally {
processTimeLineLoading.value = false
}
}
/** 选择审批人确认 */
function selectUserConfirm(id: string, userList: any[]) {
startUserSelectAssignees.value[id] = userList?.map((item: any) => item.id) || []
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
if (formData.value.startTime! >= formData.value.endTime!) {
toast.show('结束时间必须大于开始时间')
return
}
// 校验指定审批人
if (startUserSelectTasks.value.length > 0) {
for (const userTask of startUserSelectTasks.value) {
if (
Array.isArray(startUserSelectAssignees.value[userTask.id])
&& startUserSelectAssignees.value[userTask.id].length === 0
) {
toast.show(`请选择${userTask.name}的审批人`)
return
}
}
}
formLoading.value = true
try {
const submitData = { ...formData.value }
// 设置指定审批人
if (startUserSelectTasks.value.length > 0) {
submitData.startUserSelectAssignees = startUserSelectAssignees.value
}
await createLeave(submitData)
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => {
navigateBackPlus('/pages-bpm/oa/leave/index')
}, 1500)
} finally {
formLoading.value = false
}
}
// 监听表单数据变化,重新预测流程节点
watch(
() => [formData.value.startTime, formData.value.endTime, formData.value.type],
(newValue, oldValue) => {
if (!oldValue || !oldValue.some(v => v !== undefined)) {
return
}
if (newValue && newValue.some(v => v !== undefined)) {
// 记录之前的节点审批人
tempStartUserSelectAssignees.value = { ...startUserSelectAssignees.value }
startUserSelectAssignees.value = {}
// 加载最新的审批详情,主要用于节点预测
getProcessApprovalDetail()
}
},
{ deep: true },
)
// 组件初始化
onMounted(async () => {
try {
// 获取流程定义详情
const processDefinitionDetail = await getProcessDefinition(undefined, processDefineKey)
if (!processDefinitionDetail) {
toast.show('OA 请假的流程模型未配置,请检查!')
return
}
processDefinitionId.value = processDefinitionDetail.id
// 获取流程审批详情
await getProcessApprovalDetail()
} catch (error) {
console.error('初始化流程失败:', error)
toast.show('初始化流程失败,请稍后重试')
}
})
</script>

View File

@@ -0,0 +1,76 @@
<template>
<view :class="embedded ? '' : 'yd-page-container'">
<!-- 顶部导航栏仅路由访问时显示 -->
<wd-navbar
v-if="!embedded"
title="请假详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group :border="!embedded">
<wd-cell title="请假类型">
<dict-tag :type="DICT_TYPE.BPM_OA_LEAVE_TYPE" :value="formData.type" />
</wd-cell>
<wd-cell title="开始时间" :value="formatDateTime(formData.startTime) || '-'" />
<wd-cell title="结束时间" :value="formatDateTime(formData.endTime) || '-'" />
<wd-cell title="请假原因" :value="formData.reason || '-'" />
<wd-cell title="审批状态">
<dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="formData.status" />
</wd-cell>
<wd-cell title="创建时间" :value="formatDateTime(formData.createTime) || '-'" />
</wd-cell-group>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Leave } from '@/api/bpm/oa/leave'
import { onMounted, 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'
const props = defineProps<{
id?: number | string
embedded?: boolean // 是否作为嵌入组件使用(非路由访问)
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const formData = ref<Partial<Leave>>({})
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/oa/leave/index')
}
/** 获取详情数据 */
async function getDetail() {
if (!props.id) {
toast.show('参数错误')
return
}
try {
toast.loading('加载中...')
formData.value = await getLeave(Number(props.id))
} finally {
toast.close()
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>

View File

@@ -0,0 +1,218 @@
<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="view" size="32rpx" />
<text class="ml-8rpx">详情</text>
</view>
<view class="bpm-action-btn" @click.stop="handleProgress(item)">
<wd-icon name="queue" 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"
/>
<!-- 新增按钮 -->
<wd-fab
position="right-bottom"
type="primary"
:expandable="false"
@click="handleCreate"
/>
</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 { getLeavePage } from '@/api/bpm/oa/leave'
import { cancelProcessInstanceByStartUser } from '@/api/bpm/processInstance'
import { useUserStore } from '@/store'
import { navigateBackPlus } from '@/utils'
import { BpmProcessInstanceStatus, DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import LeaveSearchForm from './components/search-form.vue'
import '@/pages/bpm/styles/index.scss'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const userStore = useUserStore()
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()
}
/** 查询列表 */
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) {
uni.showModal({
title: '取消流程',
editable: true,
placeholderText: '请输入取消原因',
success: async (res) => {
if (!res.confirm) {
return
}
const reason = res.content?.trim()
if (!reason) {
uni.showToast({ title: '请输入取消原因', icon: 'none' })
return
}
await cancelProcessInstanceByStartUser(String(item.processInstanceId), reason)
// 更新状态
uni.showToast({ title: '取消成功', icon: 'success' })
item.status = BpmProcessInstanceStatus.CANCEL
item.endTime = new Date()
},
})
}
/** 发起请假 */
function handleCreate() {
uni.navigateTo({ url: '/pages-bpm/oa/leave/create/index' })
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,153 @@
<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-input
v-model="formData.name"
placeholder="请输入表达式名字"
clearable
/>
</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.COMMON_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-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({
name: undefined as string | undefined,
status: -1, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined],
})
// 时间范围选择器状态
const visibleCreateTime = ref<[boolean, boolean]>([false, false])
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()])
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`名字:${formData.name}`)
}
if (formData.status !== -1) {
conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`创建时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索流程表达式'
})
/** 创建时间[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', {
...formData,
status: formData.status === -1 ? undefined : formData.status,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.status = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,131 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程表达式详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="编号" :value="formData?.id" />
<wd-cell title="表达式名字" :value="formData?.name" />
<wd-cell title="表达式状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="表达式">
<view class="break-all">
{{ formData?.expression }}
</view>
</wd-cell>
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['bpm:process-expression:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['bpm:process-expression:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ProcessExpression } from '@/api/bpm/process-expression'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteProcessExpression, getProcessExpression } from '@/api/bpm/process-expression'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<ProcessExpression>()
const deleting = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/process-expression/index')
}
/** 加载流程表达式详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getProcessExpression(props.id)
} finally {
toast.close()
}
}
/** 编辑流程表达式 */
function handleEdit() {
uni.navigateTo({
url: `/pages-bpm/process-expression/form/index?id=${props.id}`,
})
}
/** 删除流程表达式 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该流程表达式吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteProcessExpression(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,140 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="表达式名字"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入表达式名字"
/>
<wd-cell title="表达式状态" title-width="180rpx" prop="status" center>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-textarea
v-model="formData.expression"
label="表达式"
label-width="180rpx"
prop="expression"
clearable
placeholder="请输入表达式"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { ProcessExpression } from '@/api/bpm/process-expression'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createProcessExpression, getProcessExpression, updateProcessExpression } from '@/api/bpm/process-expression'
import { getIntDictOptions } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑流程表达式' : '新增流程表达式')
const formLoading = ref(false)
const formData = ref<ProcessExpression>({
id: undefined,
name: '',
status: CommonStatusEnum.ENABLE,
expression: '',
})
const formRules = {
name: [{ required: true, message: '表达式名字不能为空' }],
status: [{ required: true, message: '表达式状态不能为空' }],
expression: [{ required: true, message: '表达式不能为空' }],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/process-expression/index')
}
/** 加载流程表达式详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getProcessExpression(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateProcessExpression(formData.value)
toast.success('修改成功')
} else {
await createProcessExpression(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,161 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程表达式管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 流程表达式列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between gap-16rpx">
<view class="min-w-0 flex-1 truncate text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<view class="shrink-0">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
</view>
<view class="mb-12rpx text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">表达式</text>
<text class="break-all">{{ item.expression }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间</text>
<text class="line-clamp-1">{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无流程表达式数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['bpm:process-expression:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { ProcessExpression } from '@/api/bpm/process-expression'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getProcessExpressionPage } from '@/api/bpm/process-expression'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<ProcessExpression[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询流程表达式列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getProcessExpressionPage(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 handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增流程表达式 */
function handleAdd() {
uni.navigateTo({
url: '/pages-bpm/process-expression/form/index',
})
}
/** 查看详情 */
function handleDetail(item: ProcessExpression) {
uni.navigateTo({
url: `/pages-bpm/process-expression/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,94 @@
<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-input
v-model="formData.name"
placeholder="请输入监听器名字"
clearable
/>
</view>
<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="">
全部
</wd-radio>
<wd-radio
v-for="dict in getStrDictOptions(DICT_TYPE.BPM_PROCESS_LISTENER_TYPE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</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, getStrDictOptions } from '@/hooks/useDict'
import { getNavbarHeight } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
name: undefined as string | undefined,
type: '', // 空字符串表示全部
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`名字:${formData.name}`)
}
if (formData.type) {
conditions.push(`类型:${getDictLabel(DICT_TYPE.BPM_PROCESS_LISTENER_TYPE, formData.type)}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索流程监听器'
})
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
type: formData.type || undefined,
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.type = ''
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,138 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程监听器详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="编号" :value="formData?.id" />
<wd-cell title="监听器名字" :value="formData?.name" />
<wd-cell title="监听器类型">
<dict-tag :type="DICT_TYPE.BPM_PROCESS_LISTENER_TYPE" :value="formData?.type" />
</wd-cell>
<wd-cell title="监听器状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="监听事件" :value="formData?.event" />
<wd-cell title="值类型">
<dict-tag :type="DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE" :value="formData?.valueType" />
</wd-cell>
<wd-cell title="值">
<view class="break-all">
{{ formData?.value }}
</view>
</wd-cell>
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['bpm:process-listener:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['bpm:process-listener:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ProcessListener } from '@/api/bpm/process-listener'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteProcessListener, getProcessListener } from '@/api/bpm/process-listener'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<ProcessListener>()
const deleting = ref(false)
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/process-listener/index')
}
/** 加载流程监听器详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getProcessListener(props.id)
} finally {
toast.close()
}
}
/** 编辑流程监听器 */
function handleEdit() {
uni.navigateTo({
url: `/pages-bpm/process-listener/form/index?id=${props.id}`,
})
}
/** 删除流程监听器 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该流程监听器吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteProcessListener(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,220 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="监听器名字"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入监听器名字"
/>
<wd-cell title="监听器状态" title-width="180rpx" prop="status" center>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-cell title="监听器类型" title-width="180rpx" prop="type" center>
<wd-radio-group v-model="formData.type" shape="button" @change="handleTypeChange">
<wd-radio
v-for="dict in getStrDictOptions(DICT_TYPE.BPM_PROCESS_LISTENER_TYPE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-cell title="监听事件" title-width="180rpx" prop="event" center>
<wd-radio-group v-model="formData.event" shape="button">
<wd-radio
v-for="option in eventOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-cell title="值类型" title-width="180rpx" prop="valueType" center>
<wd-radio-group v-model="formData.valueType" shape="button" @change="handleValueTypeChange">
<wd-radio
v-for="dict in getStrDictOptions(DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-input
v-model="formData.value"
:label="valueLabel"
label-width="180rpx"
prop="value"
clearable
:placeholder="valuePlaceholder"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { ProcessListener } from '@/api/bpm/process-listener'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createProcessListener, getProcessListener, updateProcessListener } from '@/api/bpm/process-listener'
import { getIntDictOptions, getStrDictOptions } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
/** 执行监听器事件选项 */
const EVENT_EXECUTION_OPTIONS = [
{ label: '开始', value: 'start' },
{ label: '结束', value: 'end' },
]
/** 任务监听器事件选项 */
const EVENT_OPTIONS = [
{ label: '创建', value: 'create' },
{ label: '指派', value: 'assignment' },
{ label: '完成', value: 'complete' },
{ label: '删除', value: 'delete' },
{ label: '更新', value: 'update' },
{ label: '超时', value: 'timeout' },
]
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑流程监听器' : '新增流程监听器')
const formLoading = ref(false)
const formData = ref<ProcessListener>({
id: undefined,
name: '',
type: '',
status: CommonStatusEnum.ENABLE,
event: '',
valueType: '',
value: '',
})
const formRules = {
name: [{ required: true, message: '监听器名字不能为空' }],
type: [{ required: true, message: '监听器类型不能为空' }],
status: [{ required: true, message: '监听器状态不能为空' }],
event: [{ required: true, message: '监听事件不能为空' }],
valueType: [{ required: true, message: '值类型不能为空' }],
value: [{ required: true, message: '值不能为空' }],
}
const formRef = ref<FormInstance>()
/** 根据类型获取事件选项 */
const eventOptions = computed(() => {
return formData.value.type === 'execution' ? EVENT_EXECUTION_OPTIONS : EVENT_OPTIONS
})
/** 值标签 */
const valueLabel = computed(() => {
return formData.value.valueType === 'class' ? '类路径' : '表达式'
})
/** 值占位符 */
const valuePlaceholder = computed(() => {
return formData.value.valueType === 'class' ? '请输入类路径' : '请输入表达式'
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/process-listener/index')
}
/** 类型变更时清空事件 */
function handleTypeChange() {
formData.value.event = ''
}
/** 值类型变更时清空值 */
function handleValueTypeChange() {
formData.value.value = ''
}
/** 加载流程监听器详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getProcessListener(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateProcessListener(formData.value)
toast.success('修改成功')
} else {
await createProcessListener(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,168 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程监听器管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 流程监听器列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between gap-16rpx">
<view class="min-w-0 flex-1 truncate text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<view class="shrink-0">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">监听器类型</text>
<dict-tag :type="DICT_TYPE.BPM_PROCESS_LISTENER_TYPE" :value="item.type" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">监听事件</text>
<text>{{ item.event }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">值类型</text>
<dict-tag :type="DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE" :value="item.valueType" />
</view>
<view class="mb-12rpx text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]"></text>
<text class="break-all">{{ item.value }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无流程监听器数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['bpm:process-listener:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { ProcessListener } from '@/api/bpm/process-listener'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getProcessListenerPage } from '@/api/bpm/process-listener'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<ProcessListener[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询流程监听器列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getProcessListenerPage(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 handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增流程监听器 */
function handleAdd() {
uni.navigateTo({
url: '/pages-bpm/process-listener/form/index',
})
}
/** 查看详情 */
function handleDetail(item: ProcessListener) {
uni.navigateTo({
url: `/pages-bpm/process-listener/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,284 @@
<template>
<!-- TODO vben 对应的地址/Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/processInstance/create/index.vue -->
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="发起申请"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索框 -->
<wd-search
v-model="searchName"
placeholder="请输入流程名称"
placeholder-left
hide-cancel
@search="handleSearch"
@clear="handleSearch"
/>
<!-- 分类标签 -->
<wd-tabs
v-model="activeCategory"
slidable="always"
sticky
@click="handleTabClick"
>
<wd-tab v-for="item in categoryList" :key="item.code" :title="item.name" :name="item.code" />
</wd-tabs>
<!-- 流程定义列表 -->
<scroll-view
scroll-y
class="h-[calc(100vh-320rpx)]"
:scroll-into-view="scrollIntoView"
scroll-with-animation
@scroll="handleScroll"
>
<view
v-for="item in categoryList"
:id="`category-${item.code}`"
:key="item.code"
class="category-section mx-24rpx mt-24rpx"
:data-category="item.code"
>
<!-- 分类标题 -->
<view class="mb-16rpx flex items-center">
<text class="text-28rpx text-[#333] font-bold">{{ item.name }}</text>
</view>
<!-- 流程列表 -->
<view v-if="groupedDefinitions[item.code]?.length" class="overflow-hidden rounded-16rpx bg-white">
<view
v-for="definition in groupedDefinitions[item.code]"
:key="definition.id"
class="flex items-center border-b border-[#f5f5f5] p-24rpx last:border-b-0"
@click="handleSelect(definition)"
>
<wd-img
v-if="definition.icon"
:src="definition.icon"
width="64rpx"
height="64rpx"
mode="aspectFit"
radius="24rpx"
class="mr-16rpx"
/>
<view
v-else
class="mr-16rpx h-64rpx w-64rpx flex items-center justify-center rounded-12rpx"
:style="{ backgroundColor: getIconColor(definition.name) }"
>
<text class="text-24rpx text-white font-bold">{{ getIconText(definition.name) }}</text>
</view>
<text class="text-28rpx text-[#333]">{{ definition.name }}</text>
</view>
</view>
<view v-else class="overflow-hidden rounded-16rpx bg-white p-24rpx text-center">
<text class="text-26rpx text-[#999]">该分类下暂无流程</text>
</view>
</view>
<!-- 空状态 -->
<view v-if="categoryList.length === 0" class="py-100rpx">
<wd-status-tip image="content" tip="暂无可发起的流程" />
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import type { Category } from '@/api/bpm/category'
import type { ProcessDefinition } from '@/api/bpm/definition'
import { onLoad } from '@dcloudio/uni-app'
import { computed, nextTick, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getCategorySimpleList } from '@/api/bpm/category'
import { getProcessDefinitionList } from '@/api/bpm/definition'
import { getMobileFormCustomPath } from '@/pages-bpm/utils'
import { navigateBackPlus } from '@/utils'
import { BpmModelFormType } from '@/utils/constants'
// TODO @芋艿:【重新发起流程】支持通过 processInstanceId 参数重新发起已有流程
// 对应 vben 第 44-60 行:从路由获取 processInstanceId查询流程实例后自动选中对应流程定义并填充表单数据
// TODO @芋艿:【流程表单填写】选择流程后跳转到表单填写页面
// 对应 vben form.vue 全部:包含以下子功能:
// - 表单渲染 (form-create)vben form.vue 第 145-152 行
// - 审批流程预览时间线vben form.vue 第 153-162 行
// - 流程图预览 (BPMN/简易)vben form.vue 第 163-178 行
// - 发起人自选审批人vben form.vue 第 30-32, 85-95 行
// - 表单字段权限控制 (读/写/隐藏)vben form.vue 第 119-131 行
// - 业务表单跳转 (formCustomCreatePath)vben form.vue 第 79-85 行
// - 表单值变化重新预测审批节点vben form.vue 第 87-102 行
// - 提交流程实例vben form.vue 第 56-76 行
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const searchName = ref('')
const activeCategory = ref('')
const categoryList = ref<Category[]>([])
const categoryPositions = ref<{ code: string, top: number }[]>([]) // 分类区域位置信息(用于滚动时自动切换 tab
const scrollIntoView = ref('')
const isTabClicking = ref(false) // 是否正在通过点击 tab 触发滚动(避免滚动事件反向更新 tab
const definitionList = ref<ProcessDefinition[]>([])
/** 根据流程名称获取图标背景色 */
function getIconColor(name: string): string {
const iconColors = ['#D98469', '#7BC67C', '#4A7FEB', '#9B7FEB', '#4A9DEB']
// 根据名称 hashcode 取模选择颜色
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0
}
return iconColors[Math.abs(hash) % iconColors.length]
}
/** 获取流程名称的前两个字符作为图标文字 */
function getIconText(name: string): string {
return name?.slice(0, 2) || ''
}
/** 过滤后的流程定义 */
const filteredDefinitions = computed(() => {
if (!searchName.value.trim()) {
return definitionList.value
}
return definitionList.value.filter(item =>
item.name.toLowerCase().includes(searchName.value.toLowerCase()),
)
})
/** 按分类分组的流程定义 */
const groupedDefinitions = computed<Record<string, ProcessDefinition[]>>(() => {
const grouped: Record<string, ProcessDefinition[]> = {}
filteredDefinitions.value.forEach((item) => {
if (!item.category)
return
if (!grouped[item.category])
grouped[item.category] = []
grouped[item.category].push(item)
})
return grouped
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages/bpm/index')
}
/** 搜索 */
async function handleSearch() {
// 搜索后重新计算分类位置
await nextTick()
updateCategoryPositions()
}
/** Tab 点击 */
function handleTabClick({ name }: { index: number, name: string }) {
isTabClicking.value = true
// 滚动到对应分类
scrollIntoView.value = ''
nextTick(() => {
scrollIntoView.value = `category-${name}`
// 300ms 后恢复滚动监听
setTimeout(() => {
isTabClicking.value = false
}, 300)
})
}
/** 滚动事件 - 自动切换 tab */
function handleScroll(e: { detail: { scrollTop: number } }) {
if (isTabClicking.value || categoryPositions.value.length === 0) {
return
}
// 找到当前滚动位置对应的分类
const scrollTop = e.detail.scrollTop
for (let i = categoryPositions.value.length - 1; i >= 0; i--) {
if (scrollTop >= categoryPositions.value[i].top - 20) {
if (activeCategory.value !== categoryPositions.value[i].code) {
activeCategory.value = categoryPositions.value[i].code
}
break
}
}
}
/** 更新分类区域位置信息 */
function updateCategoryPositions() {
const query = uni.createSelectorQuery()
query.selectAll('.category-section').boundingClientRect()
query.exec((res) => {
if (res && res[0]) {
const positions: { code: string, top: number }[] = []
const firstTop = res[0][0]?.top || 0
res[0].forEach((item: { top: number, dataset?: { category?: string } }, index: number) => {
const cat = categoryList.value[index]
if (cat) {
positions.push({
code: cat.code,
top: item.top - firstTop,
})
}
})
categoryPositions.value = positions
}
})
}
/** 选择流程定义 */
function handleSelect(item: ProcessDefinition) {
// 情况一:流程表单,提示仅允许 PC 端发起
if (item.formType === BpmModelFormType.NORMAL) {
// TODO @jason业务表单/Users/yunai/Java/yudao-ui-admin-vben-v5/apps/web-antd/src/views/bpm/processInstance/create/modules/form.vue
toast.show('流程表单仅支持 PC 端发起')
return
}
// 情况二:业务表单,跳转到对应的移动端页面
if (item.formType === BpmModelFormType.CUSTOM) {
const mobilePath = getMobileFormCustomPath(item.formCustomCreatePath)
if (mobilePath) {
uni.navigateTo({ url: mobilePath })
} else {
toast.show('该业务表单暂不支持移动端发起')
}
}
}
/** 加载分类列表 */
async function loadCategoryList() {
categoryList.value = await getCategorySimpleList()
}
/** 加载流程定义列表 */
async function loadDefinitionList() {
definitionList.value = await getProcessDefinitionList({ suspensionState: 1 })
}
/** 初始化 */
onLoad(async () => {
await Promise.all([loadCategoryList(), loadDefinitionList()])
// 默认选中第一个分类
if (categoryList.value.length > 0) {
activeCategory.value = categoryList.value[0].code
}
// 等待 DOM 渲染后计算分类位置
await nextTick()
setTimeout(() => {
updateCategoryPositions()
}, 100)
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,143 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="加签任务"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 操作表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 加签处理人 -->
<UserPicker
v-model="formData.userIds"
prop="userIds"
type="checkbox"
label="加签处理人:"
label-width="200rpx"
placeholder="请选择加签处理人"
:rules="formRules.userIds"
/>
<!-- 审批意见 -->
<wd-textarea
v-model="formData.reason"
prop="reason"
label="审批意见:"
label-width="200rpx"
placeholder="请输入审批意见"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="mt-48rpx flex gap-16rpx">
<wd-button
type="primary"
class="flex-1"
plain
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit('before')"
>
向前加签
</wd-button>
<wd-button
type="primary"
class="flex-1"
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit('after')"
>
向后加签
</wd-button>
</view>
</wd-form>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import { computed, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { signCreateTask } from '@/api/bpm/task'
import UserPicker from '@/components/system-select/user-picker.vue'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
processInstanceId: string
taskId: string
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const taskId = computed(() => props.taskId)
const processInstanceId = computed(() => props.processInstanceId)
const toast = useToast()
const formLoading = ref(false)
const formData = reactive({
userIds: [] as number[],
reason: '',
})
const formRules = {
userIds: [
{ required: true, message: '加签处理人不能为空', validator: (value: number[]) => value.length > 0 },
],
reason: [
{ required: true, message: '审批意见不能为空' },
],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
}
/** 提交操作 */
async function handleSubmit(type: 'before' | 'after') {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
formLoading.value = true
try {
await signCreateTask({
id: taskId.value as string,
type,
userIds: formData.userIds,
reason: formData.reason,
})
const actionText = type === 'before' ? '向前加签' : '向后加签'
toast.success(`${actionText}成功`)
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`,
})
}, 500)
} finally {
formLoading.value = false
}
}
/** 页面加载时 */
onMounted(() => {
/** 初始化校验 */
if (!props.taskId || !props.processInstanceId) {
toast.show('参数错误')
}
})
</script>

View File

@@ -0,0 +1,327 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="isApprove ? '审批同意' : '审批拒绝'"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 审批表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 下一个节点的审批人 -->
<view v-if="isApprove && nextAssigneesActivityNode.length > 0" class="p-24rpx">
<view class="mb-16rpx flex items-center">
<text class="mr-8rpx text-[#f56c6c]">*</text>
<text class="text-28rpx text-[#333]">下一个节点的审批人</text>
</view>
<ProcessInstanceTimeline
:activity-nodes="nextAssigneesActivityNode"
:show-status-icon="false"
:enable-approve-user-select="true"
@select-user-confirm="selectNextAssigneesConfirm"
/>
</view>
<!-- 签名 -->
<view v-if="isApprove && taskInfo?.signEnable" class="border-b border-[#eee] p-24rpx">
<view class="mb-16rpx flex items-center">
<text class="mr-8rpx text-[#f56c6c]">*</text>
<text class="text-28rpx text-[#333]">签名</text>
</view>
<view class="flex items-center gap-16rpx">
<wd-button type="primary" size="small" @click="openSignatureModal">
{{ formData.signPicUrl ? '重新签名' : '点击签名' }}
</wd-button>
<image
v-if="formData.signPicUrl"
:src="formData.signPicUrl"
class="h-80rpx w-192rpx"
mode="aspectFit"
@click="previewSignature"
/>
</view>
</view>
<!-- 审批意见 -->
<wd-textarea
v-model="formData.reason"
prop="reason"
label="审批意见:"
label-width="180rpx"
placeholder="请输入审批意见"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
</wd-form>
<!-- 提交按钮 -->
<view class="mt-48rpx">
<wd-button
:type="isApprove ? 'primary' : 'error'"
block
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit"
>
{{ isApprove ? '同意' : '拒绝' }}
</wd-button>
</view>
</view>
<!-- 签名弹窗 -->
<wd-popup v-model="showSignatureModal" position="bottom" custom-style="height: 60vh;">
<view class="h-full flex flex-col">
<view class="flex items-center justify-between border-b border-[#eee] p-24rpx">
<text class="text-32rpx text-[#333] font-bold">手写签名</text>
<wd-icon name="close" size="40rpx" @click="showSignatureModal = false" />
</view>
<view class="flex-1 p-24rpx">
<wd-signature
:height="300"
:export-scale="2"
background-color="#ffffff"
@confirm="handleSignatureConfirm"
@clear="handleSignatureClear"
/>
</view>
</view>
</wd-popup>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { ApprovalNodeInfo } from '@/api/bpm/processInstance'
import type { Task } from '@/api/bpm/task'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getApprovalDetail, getNextApproveNodes } from '@/api/bpm/processInstance'
import { approveTask, rejectTask } from '@/api/bpm/task'
import ProcessInstanceTimeline from '@/pages-bpm/processInstance/detail/components/time-line.vue'
import { getEnvBaseUrl, navigateBackPlus } from '@/utils'
import { BpmCandidateStrategyEnum } from '@/utils/constants'
const props = defineProps<{
processInstanceId?: string
taskId?: string
pass?: string
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const taskId = computed(() => props.taskId || '')
const processInstanceId = computed(() => props.processInstanceId)
const isApprove = computed(() => props.pass !== 'false') // true: 同意, false: 拒绝
const toast = useToast()
const formLoading = ref(false)
const taskInfo = ref<Task | null>(null) // 任务信息
const nextAssigneesActivityNode = ref<ApprovalNodeInfo[]>([]) // 下一个节点审批人列表
const approveUserSelectTasks = ref<ApprovalNodeInfo[]>([]) // 需要选择审批人的节点列表
const approveUserSelectAssignees = ref<Record<string, number[]>>({}) // 审批人选择的审批人数据
// 签名相关
const showSignatureModal = ref(false)
const formData = reactive({
reason: '',
signPicUrl: '', // 签名图片 URL
})
const formRules = computed(() => {
let rules = {}
if (taskInfo.value?.reasonRequire) {
rules = {
reason: [
{ required: true, message: '审批意见不能为空' },
],
}
}
return rules
})
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
}
/** 加载任务信息 */
async function loadTaskInfo() {
const data = await getApprovalDetail({
processInstanceId: processInstanceId.value,
taskId: taskId.value,
})
taskInfo.value = data?.todoTask || null
}
/** 加载下一个节点审批人 */
async function loadNextApproveNodes() {
if (!isApprove.value) {
return
}
const params = {
processInstanceId: processInstanceId.value,
taskId: taskId.value,
}
const data = await getNextApproveNodes(params)
if (data && data.length > 0) {
nextAssigneesActivityNode.value = data
// 获取审批人自选的任务
approveUserSelectTasks.value = data.filter(
(node: ApprovalNodeInfo) =>
BpmCandidateStrategyEnum.APPROVE_USER_SELECT === node.candidateStrategy,
) || []
}
}
/** 选择下一个节点审批人确认 */
function selectNextAssigneesConfirm(activityId: string, userList: any[]) {
approveUserSelectAssignees.value[activityId] = userList.map(user => user.id)
}
/** 打开签名弹窗 */
function openSignatureModal() {
showSignatureModal.value = true
}
/** 签名确认 */
async function handleSignatureConfirm(result: { tempFilePath: string, base64: string }) {
toast.loading('上传中...')
try {
// 上传签名图片
const url = await uploadSignatureFile(result.tempFilePath)
formData.signPicUrl = url
showSignatureModal.value = false
toast.success('签名成功')
} catch (err) {
console.error('上传失败:', err)
toast.show('上传失败')
}
}
/** 上传签名文件 */
function uploadSignatureFile(tempFilePath: string): Promise<string> {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${getEnvBaseUrl()}/infra/file/upload`,
filePath: tempFilePath,
name: 'file',
success: (uploadFileRes) => {
try {
const data = JSON.parse(uploadFileRes.data)
if (data.code === 0 && data.data) {
resolve(data.data)
} else {
reject(new Error(data.msg || '上传失败'))
}
} catch (err) {
reject(err)
}
},
fail: (err) => {
console.error('上传失败:', err)
reject(err)
},
})
})
}
/** 签名清除 */
function handleSignatureClear() {
formData.signPicUrl = ''
}
/** 预览签名 */
function previewSignature() {
if (formData.signPicUrl) {
uni.previewImage({
urls: [formData.signPicUrl],
current: formData.signPicUrl,
})
}
}
/** 提交审批 */
async function handleSubmit() {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
// 验证签名
if (isApprove.value && taskInfo.value?.signEnable && !formData.signPicUrl) {
toast.show('请先进行签名')
return
}
// 验证审批人选择
if (isApprove.value && approveUserSelectTasks.value.length > 0) {
for (const task of approveUserSelectTasks.value) {
if (!approveUserSelectAssignees.value[task.id] || approveUserSelectAssignees.value[task.id].length === 0) {
toast.show(`请选择「${task.name}」的审批人`)
return
}
}
}
formLoading.value = true
try {
if (isApprove.value) {
// 审批通过
await approveTask({
id: taskId.value as string,
reason: formData.reason,
signPicUrl: formData.signPicUrl || undefined,
nextAssignees: Object.keys(approveUserSelectAssignees.value).length > 0
? approveUserSelectAssignees.value
: undefined,
})
} else {
// 审批拒绝
await rejectTask({
id: taskId.value as string,
reason: formData.reason,
})
}
toast.success('审批成功')
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`,
})
}, 1000)
} finally {
formLoading.value = false
}
}
/** 页面加载时 */
onMounted(async () => {
/** 初始化校验 */
if (!props.taskId || !props.processInstanceId) {
toast.show('参数错误')
return
}
try {
toast.loading('加载中...')
// 加载任务信息和下一个节点审批人
await loadTaskInfo()
await loadNextApproveNodes()
} finally {
toast.close()
}
})
</script>

View File

@@ -0,0 +1,42 @@
<!-- 表单详情流程表单/业务表单 -->
<template>
<view class="mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
<!-- 标题 -->
<view class="px-24rpx pt-24rpx text-28rpx text-[#333] font-bold">
审批详情
</view>
<!-- 表单内容业务表单 -->
<template v-if="processDefinition?.formType === BpmModelFormType.CUSTOM">
<!-- OA 请假详情 -->
<LeaveDetail
v-if="processDefinition?.formCustomViewPath === '/bpm/oa/leave/detail'"
:id="processInstance?.businessKey"
embedded
/>
<!-- 未配置的业务表单 -->
<view v-else class="px-24rpx py-32rpx text-26rpx text-[#999]">
暂不支持该业务表单请参考 LeaveDetail 配置
</view>
</template>
<!-- TODO @jason表单内容流程表单 -->
<template v-else-if="processDefinition?.formType === BpmModelFormType.NORMAL">
<view class="px-24rpx py-32rpx text-26rpx text-[#999]">
流程表单仅 PC 端支持预览
</view>
</template>
</view>
</template>
<script lang="ts" setup>
import type { ProcessDefinition, ProcessInstance } from '@/api/bpm/processInstance'
// 特殊业务表单组件uniapp 小程序不支持动态组件,需要静态导入)
import LeaveDetail from '@/pages-bpm/oa/leave/detail/index.vue'
import { BpmModelFormType } from '@/utils/constants'
defineProps<{
/** 流程定义 */
processDefinition?: ProcessDefinition
/** 流程实例 */
processInstance?: ProcessInstance
}>()
</script>

View File

@@ -0,0 +1,311 @@
<!-- 操作按钮 -->
<template>
<!-- 有待审批的任务 -->
<view v-if="runningTask" class="yd-detail-footer">
<view class="w-full flex items-center">
<!-- 左侧操作按钮 -->
<view v-for="(action, idx) in leftOperations" :key="idx" class="mr-32rpx w-60rpx flex flex-col items-center" @click="handleOperation(action.operationType)">
<wd-icon :name="action.iconName" size="40rpx" color="#1890ff" />
<text class="mt-4rpx text-22rpx text-[#333]">{{ action.displayName }}</text>
</view>
<!-- 更多操作按钮 -->
<view v-if="moreOperations.length > 0" class="mr-32rpx w-60rpx flex flex-col items-center" @click="handleShowMore">
<wd-icon name="ellipsis" size="40rpx" color="#1890ff" />
<text class="mt-4rpx text-22rpx text-[#333]">更多</text>
</view>
<!-- 更多操作 ActionSheet -->
<wd-action-sheet v-if="moreOperations.length > 0" v-model="showMoreActions" :actions="moreOperations" title="请选择操作" @select="handleMoreAction" />
<!-- 右侧按钮TODO @jason是否一定要保留两个按钮需要的哈 -->
<view class="flex flex-1 gap-16rpx">
<wd-button
v-for="(action, idx) in rightOperations"
:key="idx"
:plain="action.plain"
:type="action.btnType"
:round="false"
class="flex-1"
custom-style="min-width: 200rpx; width: 200rpx;"
@click="handleOperation(action.operationType)"
>
{{ action.displayName }}
</wd-button>
</view>
</view>
</view>
<!-- 无待审批的任务 仅显示取消按钮TODO @jason看看还需要显示这个微信交流下 -->
<view v-if="!runningTask && isShowProcessStartCancel()" class="yd-detail-footer">
<wd-button
plain
type="primary"
:round="false"
block
@click="handleOperation(BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL)"
>
取消
</wd-button>
</view>
</template>
<script lang="ts" setup>
import type { Action } from 'wot-design-uni/components/wd-action-sheet/types'
import type { ButtonType } from 'wot-design-uni/components/wd-button/types'
import type { ProcessInstance } from '@/api/bpm/processInstance'
import type { Task } from '@/api/bpm/task'
import { useUserStore } from '@/store'
import {
BpmProcessInstanceStatus,
BpmTaskOperationButtonTypeEnum,
BpmTaskStatusEnum,
OPERATION_BUTTON_NAME,
} from '@/utils/constants'
const showMoreActions = ref(false)
type MoreOperationType = Action & {
operationType: number
}
interface LeftOperationType {
operationType: number
iconName: string
displayName: string
}
interface RightOperationType {
operationType: number
btnType: ButtonType
displayName: string
plain: boolean
}
const operationIconsMap: Record<number, string> = {
[BpmTaskOperationButtonTypeEnum.TRANSFER]: 'transfer',
[BpmTaskOperationButtonTypeEnum.ADD_SIGN]: 'add',
[BpmTaskOperationButtonTypeEnum.DELEGATE]: 'share',
[BpmTaskOperationButtonTypeEnum.RETURN]: 'arrow-left',
[BpmTaskOperationButtonTypeEnum.COPY]: 'copy',
[BpmTaskOperationButtonTypeEnum.DELETE_SIGN]: 'remove',
[BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL]: 'stop-circle',
}
const userStore = useUserStore()
const leftOperations = ref<LeftOperationType[]>([]) // 左侧操作按钮 【最多两个】{转办, 委派, 退回, 加签, 抄送等}
const rightOperationTypes = [] // 右侧操作按钮【最多两个】{通过,拒绝, 取消}
const rightOperations = ref<RightOperationType[]>([])
const moreOperations = ref<MoreOperationType[]>([]) // 更多操作
const runningTask = ref<Task>()
const processInstance = ref<ProcessInstance>()
const reasonRequire = ref<boolean>(false)
/** 初始化 */
function init(theProcessInstance: ProcessInstance, task: Task) {
processInstance.value = theProcessInstance
runningTask.value = task
if (task) {
reasonRequire.value = task.reasonRequire ?? false
// TODO @jason这里的判断是否可以简化哈就是默认计算出按钮然后根据数量去渲染具体的按钮。
// 右侧按钮
if (isHandleTaskStatus() && isShowButton(BpmTaskOperationButtonTypeEnum.REJECT)) {
rightOperationTypes.push(BpmTaskOperationButtonTypeEnum.REJECT)
rightOperations.value.push({
operationType: BpmTaskOperationButtonTypeEnum.REJECT,
displayName: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.REJECT),
btnType: 'error',
plain: true,
})
}
if (isHandleTaskStatus() && isShowButton(BpmTaskOperationButtonTypeEnum.APPROVE)) {
rightOperationTypes.push(BpmTaskOperationButtonTypeEnum.APPROVE)
rightOperations.value.push({
operationType: BpmTaskOperationButtonTypeEnum.APPROVE,
displayName: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.APPROVE),
btnType: 'primary',
plain: false,
})
}
// 左侧操作,和更多操作
Object.keys(task.buttonsSetting || {}).forEach((key) => {
const operationType = Number(key)
if (task.buttonsSetting[key].enable && isHandleTaskStatus()
&& !rightOperationTypes.includes(operationType)) {
if (leftOperations.value.length >= 2) {
moreOperations.value.push({
name: getButtonDisplayName(operationType),
operationType,
})
} else {
leftOperations.value.push({
operationType,
iconName: operationIconsMap[operationType],
displayName: getButtonDisplayName(operationType),
})
}
}
})
// 减签操作的显示
if (isShowDeleteSign()) {
if (leftOperations.value.length >= 2) {
moreOperations.value.push({
name: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.DELETE_SIGN),
operationType: BpmTaskOperationButtonTypeEnum.DELETE_SIGN,
})
} else {
leftOperations.value.push({
operationType: BpmTaskOperationButtonTypeEnum.DELETE_SIGN,
iconName: operationIconsMap[BpmTaskOperationButtonTypeEnum.DELETE_SIGN],
displayName: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.DELETE_SIGN),
})
}
}
}
// 是否显示流程取消
if (isShowProcessStartCancel()) {
if (rightOperationTypes.length < 2) {
rightOperationTypes.push(BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL)
rightOperations.value.push({
operationType: BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL,
displayName: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL),
btnType: 'primary',
plain: true,
})
} else {
if (leftOperations.value.length >= 2) {
moreOperations.value.push({
name: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL),
operationType: BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL,
})
} else {
leftOperations.value.push({
operationType: BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL,
iconName: operationIconsMap[BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL],
displayName: getButtonDisplayName(BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL),
})
}
}
}
}
/** 跳转到相应的操作页面 */
function handleOperation(operationType: number) {
switch (operationType) {
case BpmTaskOperationButtonTypeEnum.APPROVE:
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/audit/index?processInstanceId=${processInstance.value.id}&taskId=${runningTask.value?.id}&pass=true` })
break
case BpmTaskOperationButtonTypeEnum.REJECT:
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/audit/index?processInstanceId=${processInstance.value.id}&taskId=${runningTask.value?.id}&pass=false` })
break
case BpmTaskOperationButtonTypeEnum.DELEGATE:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/reassign/index?processInstanceId=${runningTask.value.processInstanceId}&taskId=${runningTask.value.id}&type=delegate`,
})
break
case BpmTaskOperationButtonTypeEnum.TRANSFER:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/reassign/index?processInstanceId=${runningTask.value.processInstanceId}&taskId=${runningTask.value.id}&type=transfer`,
})
break
case BpmTaskOperationButtonTypeEnum.ADD_SIGN:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/add-sign/index?processInstanceId=${runningTask.value.processInstanceId}&taskId=${runningTask.value.id}`,
})
break
case BpmTaskOperationButtonTypeEnum.RETURN:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/return/index?processInstanceId=${runningTask.value.processInstanceId}&taskId=${runningTask.value.id}`,
})
break
case BpmTaskOperationButtonTypeEnum.DELETE_SIGN:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/delete-sign/index?processInstanceId=${runningTask.value.processInstanceId}&taskId=${runningTask.value.id}&children=${encodeURIComponent(JSON.stringify(runningTask.value.children || []))}`,
})
break
case BpmTaskOperationButtonTypeEnum.PROCESS_START_CANCEL:
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/process-cancel/index?processInstanceId=${processInstance.value.id}&taskId=${runningTask.value?.id}`,
})
break
}
}
/** 显示更多操作 */
function handleShowMore() {
showMoreActions.value = true
}
/** 处理更多操作选择 */
function handleMoreAction(action: { item: MoreOperationType }) {
handleOperation(action.item.operationType)
showMoreActions.value = false
}
/** 获取按钮的显示名称 */
function getButtonDisplayName(btnType: BpmTaskOperationButtonTypeEnum) {
let displayName = OPERATION_BUTTON_NAME.get(btnType)
if (
runningTask.value?.buttonsSetting
&& runningTask.value?.buttonsSetting[btnType]
) {
displayName = runningTask.value.buttonsSetting[btnType].displayName
}
return displayName
}
/** 是否显示按钮 */
function isShowButton(btnType: BpmTaskOperationButtonTypeEnum): boolean {
let isShow = true
if (
runningTask.value?.buttonsSetting
&& runningTask.value?.buttonsSetting[btnType]
) {
isShow = runningTask.value.buttonsSetting[btnType].enable
}
return isShow
}
/** 任务是否为处理中状态 */
function isHandleTaskStatus() {
let canHandle = false
if (BpmTaskStatusEnum.RUNNING === runningTask.value?.status) {
canHandle = true
}
return canHandle
}
/** 流程状态是否为结束状态 */
function isEndProcessStatus(status: number) {
let isEndStatus = false
if (
BpmProcessInstanceStatus.APPROVE === status
|| BpmProcessInstanceStatus.REJECT === status
|| BpmProcessInstanceStatus.CANCEL === status
) {
isEndStatus = true
}
return isEndStatus
}
/** 流程发起人是否为当前用户 */
function isProcessStartUser() {
let isStartUser = false
if (userStore.userInfo?.userId === processInstance.value?.startUser?.id) {
isStartUser = true
}
return isStartUser
}
/** 是否显示减签 */
function isShowDeleteSign() {
return runningTask.value?.children?.length > 0
}
/** 是否显示流程发起人取消 */
function isShowProcessStartCancel() {
return isProcessStartUser() && !isEndProcessStatus(processInstance.value?.status)
}
/** 暴露方法 */
defineExpose({ init })
</script>

View File

@@ -0,0 +1,394 @@
<template>
<!-- 遍历每个审批节点 -->
<view
v-for="(activity, index) in activityNodes"
:key="activity.id || index"
class="relative pb-24rpx pl-80rpx"
>
<!-- 时间线圆点 -->
<view
class="absolute left-12rpx top-8rpx h-52rpx w-52rpx flex items-center justify-center rounded-full bg-blue-500"
>
<!-- 节点类型图标 -->
<wd-icon
:name="getApprovalNodeTypeIcon(activity.nodeType)"
size="32rpx"
color="white"
/>
</view>
<!-- 状态小图标 -->
<view
v-if="showStatusIcon"
class="absolute left-48rpx top-44rpx h-16rpx w-16rpx flex items-center justify-center border-2 border-white rounded-full"
:style="{ backgroundColor: getApprovalNodeColor(activity.status) }"
>
<wd-icon
:name="getApprovalNodeIcon(activity.status, activity.nodeType)"
size="12rpx"
color="white"
/>
</view>
<!-- 连接线 -->
<view
v-if="index < activityNodes.length - 1"
class="absolute bottom-0 left-38rpx top-64rpx w-2rpx bg-[#e5e5e5]"
/>
<!-- 节点内容 -->
<view class="ml-8rpx">
<!-- 第一行节点名称时间 -->
<view class="mb-8rpx flex items-center justify-between">
<view class="flex items-center">
<text class="text-28rpx text-[#333] font-bold">{{ activity.name }}</text>
<text v-if="activity.status === BpmTaskStatusEnum.SKIP" class="ml-8rpx text-24rpx text-[#999]">
跳过
</text>
</view>
<text
v-if="activity.status !== BpmTaskStatusEnum.NOT_START && getApprovalNodeTime(activity)"
class="text-22rpx text-[#999]"
>
{{ getApprovalNodeTime(activity) }}
</text>
</view>
<!-- 子流程节点 -->
<view v-if="activity.nodeType === BpmNodeTypeEnum.CHILD_PROCESS_NODE" class="mb-16rpx">
<wd-button
type="primary"
plain
size="small"
:disabled="!activity.processInstanceId"
@click="handleChildProcess(activity)"
>
查看子流程
</wd-button>
</view>
<!-- 需要自定义选择审批人 -->
<view v-if="shouldShowCustomUserSelect(activity)" class="mb-16rpx">
<view class="flex flex-wrap items-center">
<!-- 添加用户按钮 -->
<UserPicker
:model-value="getSelectedUserIds(activity.id)"
type="checkbox"
use-default-slot
@confirm="(users) => handleCustomUserSelectConfirm(activity.id, users)"
>
<view
class="mb-8rpx mr-16rpx h-48rpx w-48rpx flex items-center justify-center border-indigo-500 rounded-lg border-solid"
>
<wd-icon name="user-add" size="32rpx" color="blue" />
</view>
</UserPicker>
<!-- 已选择的用户 -->
<view
v-for="(user, userIndex) in customApproveUsers[activity.id]"
:key="user.id || userIndex"
class="mb-8rpx mr-16rpx flex items-center rounded-32rpx bg-[#f5f5f5] pr-16rpx"
>
<view class="mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
{{ user.nickname?.[0] || '?' }}
</view>
<text class="text-24rpx text-[#333]">{{ user.nickname }}</text>
</view>
</view>
</view>
<!-- 审批人员列表 -->
<view v-else class="mb-16rpx">
<!-- 情况一遍历每个审批节点下的进行中task 任务 -->
<view v-if="activity.tasks && activity.tasks.length > 0">
<view
v-for="(task, taskIndex) in activity.tasks"
:key="taskIndex"
class="mb-16rpx"
>
<!-- 审批人信息 -->
<view v-if="task.assigneeUser || task.ownerUser" class="mb-8rpx flex items-center">
<!-- TODO @jason 用户头像显示 -->
<view class="relative mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
{{ (task.assigneeUser?.nickname || task.ownerUser?.nickname)?.[0] || '?' }}
<!-- 任务状态小图标 -->
<view
v-if="showStatusIcon && shouldShowTaskStatusIcon(task.status)"
class="absolute right--4rpx top-36rpx h-16rpx w-16rpx flex items-center justify-center border-2 border-white rounded-full"
:style="{ backgroundColor: getApprovalNodeColor(task.status) }"
>
<wd-icon
:name="getApprovalNodeIcon(task.status, activity.nodeType)"
size="12rpx"
color="white"
/>
</view>
</view>
<view class="flex-1">
<view class="flex items-center justify-between">
<view class="flex items-center">
<text class="text-26rpx text-[#333]">
{{ task.assigneeUser?.nickname || task.ownerUser?.nickname }}
</text>
<text
v-if="task.assigneeUser?.deptName || task.ownerUser?.deptName"
class="ml-8rpx text-22rpx text-[#999]"
>
{{ task.assigneeUser?.deptName || task.ownerUser?.deptName }}
</text>
</view>
</view>
<view class="mt-4rpx flex items-center">
<text :class="getStatusTextClass(task.status)" class="text-24rpx">
{{ getStatusText(task.status) }}
</text>
</view>
</view>
</view>
<!-- 审批意见 -->
<view
v-if="shouldShowApprovalReason(task, activity.nodeType)"
class="mt-8rpx rounded-8rpx bg-[#f5f5f5] p-16rpx"
>
<text class="text-24rpx text-[#666]">审批意见{{ task.reason }}</text>
</view>
<!-- 签名 -->
<view
v-if="task.signPicUrl && activity.nodeType === BpmNodeTypeEnum.USER_TASK_NODE"
class="mt-8rpx flex items-center rounded-8rpx bg-[#f5f5f5] p-16rpx"
>
<text class="text-24rpx text-[#666]">签名</text>
<image
:src="task.signPicUrl"
class="ml-8rpx h-96rpx w-288rpx"
mode="aspectFit"
@click="previewImage(task.signPicUrl)"
/>
</view>
</view>
</view>
<!-- 情况二遍历每个审批节点下的候选的task 任务 -->
<view v-if="activity.candidateUsers && activity.candidateUsers.length > 0">
<view
v-for="(user, userIndex) in activity.candidateUsers"
:key="userIndex"
class="mb-8rpx flex items-center"
>
<view class="relative mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-24rpx text-white">
{{ user.nickname?.[0] || '?' }}
<!-- 候选状态图标 -->
<view
v-if="showStatusIcon"
class="absolute right--4rpx top-36rpx h-16rpx w-16rpx flex items-center justify-center border-2 border-white rounded-full"
:style="{ backgroundColor: getApprovalNodeColor(BpmTaskStatusEnum.NOT_START) }"
>
<wd-icon name="time" size="12rpx" color="white" />
</view>
</view>
<view class="flex-1">
<text class="text-26rpx text-[#333]">{{ user.nickname }}</text>
<text v-if="user.deptName" class="ml-8rpx text-22rpx text-[#999]">
{{ user.deptName }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ApprovalNodeInfo } from '@/api/bpm/processInstance'
import { ref } from 'vue'
import UserPicker from '@/components/system-select/user-picker.vue'
import { BpmCandidateStrategyEnum, BpmNodeTypeEnum, BpmTaskStatusEnum } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = withDefaults(
defineProps<{
activityNodes: ApprovalNodeInfo[]
enableApproveUserSelect?: boolean
showStatusIcon?: boolean
}>(),
{
showStatusIcon: true,
enableApproveUserSelect: false,
},
)
const emit = defineEmits<{
selectUserConfirm: [activityId: string, userList: any[]]
}>()
// 状态图标映射
const statusIconMap: Record<string, { color: string, icon: string }> = {
'-2': { color: '#909398', icon: 'skip-forward' }, // 跳过
'-1': { color: '#909398', icon: 'time' }, // 审批未开始
'0': { color: '#f59e0b', icon: 'refresh1' }, // 待审批
'1': { color: '#f59e0b', icon: 'refresh1' }, // 审批中
'2': { color: '#00b32a', icon: 'check' }, // 审批通过
'3': { color: '#f46b6c', icon: 'close' }, // 审批不通过
'4': { color: '#cccccc', icon: 'delete' }, // 已取消
'5': { color: '#f46b6c', icon: 'arrow-left' }, // 退回
'6': { color: '#448ef7', icon: 'time' }, // 委派中
'7': { color: '#00b32a', icon: 'check' }, // 审批通过中
}
// 节点类型图标映射 TODO 图标重新选一下
const nodeTypeSvgMap: Record<number, { color: string, icon: string }> = {
[BpmNodeTypeEnum.END_EVENT_NODE]: { color: '#909398', icon: 'poweroff' },
[BpmNodeTypeEnum.START_USER_NODE]: { color: '#909398', icon: 'user' },
[BpmNodeTypeEnum.USER_TASK_NODE]: { color: '#ff943e', icon: 'user-talk' },
[BpmNodeTypeEnum.TRANSACTOR_NODE]: { color: '#ff943e', icon: 'edit' },
[BpmNodeTypeEnum.COPY_TASK_NODE]: { color: '#3296fb', icon: 'copy' },
[BpmNodeTypeEnum.CONDITION_NODE]: { color: '#14bb83', icon: 'branch' },
[BpmNodeTypeEnum.PARALLEL_BRANCH_NODE]: { color: '#14bb83', icon: 'branch' },
[BpmNodeTypeEnum.CHILD_PROCESS_NODE]: { color: '#14bb83', icon: 'cluster' },
}
const onlyStatusIconShow = [BpmTaskStatusEnum.NOT_START, BpmTaskStatusEnum.RUNNING, BpmTaskStatusEnum.WAIT] // 只有状态是 -1、0、1 才展示头像右小角状态小 icon
// 响应式数据
const customApproveUsers = ref<Record<string, any[]>>({})
const showUserPicker = ref(false)
const selectedUserIds = ref<number[]>([])
const selectedActivityNodeId = ref<string>()
/** 获取审批节点类型图标 */
function getApprovalNodeTypeIcon(nodeType: number) {
return nodeTypeSvgMap[nodeType]?.icon || 'time'
}
/** 获取审批节点图标 */
function getApprovalNodeIcon(taskStatus: number, nodeType: number) {
if (taskStatus === BpmTaskStatusEnum.NOT_START) {
return statusIconMap[taskStatus]?.icon || 'time'
}
return statusIconMap[taskStatus]?.icon || 'time'
}
/** 获取审批节点颜色 */
function getApprovalNodeColor(taskStatus: number) {
return statusIconMap[taskStatus]?.color || '#909398'
}
/** 获取审批节点时间 */
function getApprovalNodeTime(node: ApprovalNodeInfo) {
if (node.nodeType === BpmNodeTypeEnum.START_USER_NODE && node.startTime) {
return formatDateTime(node.startTime)
}
if (node.endTime) {
return formatDateTime(node.endTime)
}
if (node.startTime) {
return formatDateTime(node.startTime)
}
return ''
}
/** 是否显示任务状态图标 */
function shouldShowTaskStatusIcon(status: number) {
return onlyStatusIconShow.includes(status)
}
/** 判断是否需要显示自定义选择审批人 */
function shouldShowCustomUserSelect(activity: ApprovalNodeInfo) {
return (
(!activity.tasks || activity.tasks.length === 0)
&& ((BpmCandidateStrategyEnum.START_USER_SELECT === activity.candidateStrategy
&& (!activity.candidateUsers || activity.candidateUsers.length === 0))
|| (props.enableApproveUserSelect
&& BpmCandidateStrategyEnum.APPROVE_USER_SELECT === activity.candidateStrategy))
)
}
/** 判断是否需要显示审批意见 */
function shouldShowApprovalReason(task: any, nodeType: number) {
return (
task.reason
&& [BpmNodeTypeEnum.END_EVENT_NODE, BpmNodeTypeEnum.USER_TASK_NODE].includes(nodeType)
)
}
/** 获取状态文本样式类 */
function getStatusTextClass(status: number) {
const colorMap: Record<number, string> = {
[BpmTaskStatusEnum.RUNNING]: 'text-[#ff943e]',
[BpmTaskStatusEnum.APPROVE]: 'text-[#00b32a]',
[BpmTaskStatusEnum.REJECT]: 'text-[#f46b6c]',
[BpmTaskStatusEnum.CANCEL]: 'text-[#cccccc]',
[BpmTaskStatusEnum.RETURN]: 'text-[#f46b6c]',
}
return colorMap[status] || 'text-[#666]'
}
/** 获取状态文本 */
function getStatusText(status: number) {
const textMap: Record<number, string> = {
[BpmTaskStatusEnum.NOT_START]: '未开始',
[BpmTaskStatusEnum.RUNNING]: '待审批',
[BpmTaskStatusEnum.APPROVE]: '已通过',
[BpmTaskStatusEnum.REJECT]: '已拒绝',
[BpmTaskStatusEnum.CANCEL]: '已取消',
[BpmTaskStatusEnum.RETURN]: '已退回',
[BpmTaskStatusEnum.SKIP]: '已跳过',
}
return textMap[status] || '未知'
}
/** 用户选择确认 */
function handleCustomUserSelectConfirm(activityId: string, users: any[]) {
customApproveUsers.value[activityId] = users || []
emit('selectUserConfirm', activityId, users)
}
/** 获取选中的用户ID数组 */
function getSelectedUserIds(activityId: string): number[] {
const users = customApproveUsers.value[activityId] || []
return users.map(user => user.id).filter(id => id !== undefined)
}
/** 跳转子流程 */
function handleChildProcess(activity: ApprovalNodeInfo) {
if (!activity.processInstanceId) {
return
}
uni.navigateTo({
url: `/pages-bpm/processInstance/detail/index?id=${activity.processInstanceId}`,
})
}
/** 预览图片 */
function previewImage(url: string) {
uni.previewImage({
urls: [url],
current: url,
})
}
/** 设置自定义审批人 */
function setCustomApproveUsers(activityId: string, users: any[]) {
customApproveUsers.value[activityId] = users || []
}
/** 批量设置多个节点的自定义审批人 */
function batchSetCustomApproveUsers(data: Record<string, any[]>) {
Object.keys(data).forEach((activityId) => {
customApproveUsers.value[activityId] = data[activityId] || []
})
}
// 暴露方法给父组件
defineExpose({
setCustomApproveUsers,
batchSetCustomApproveUsers,
})
</script>

View File

@@ -0,0 +1,165 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="减签任务"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 操作表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 减签人员选择 -->
<wd-picker
v-model="formData.deleteSignTaskId"
:columns="taskOptions"
value-key="id"
label-key="label"
label="减签人员:"
label-width="180rpx"
placeholder="请选择减签人员"
prop="deleteSignTaskId"
/>
<!-- 审批意见 -->
<wd-textarea
v-model="formData.reason"
prop="reason"
label="审批意见:"
label-width="180rpx"
placeholder="请输入审批意见"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="mt-48rpx">
<wd-button
type="primary"
block
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit"
>
减签
</wd-button>
</view>
</wd-form>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { signDeleteTask } from '@/api/bpm/task'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
processInstanceId: string
taskId: string
children?: string // JSON 字符串格式的子任务数据
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const taskId = computed(() => props.taskId)
const processInstanceId = computed(() => props.processInstanceId)
const toast = useToast()
const formLoading = ref(false)
const taskOptions = ref<any[]>([])
const formData = reactive({
deleteSignTaskId: '',
reason: '',
})
const formRules = {
deleteSignTaskId: [
{ required: true, message: '减签人员不能为空' },
],
reason: [
{ required: true, message: '审批意见不能为空' },
],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
}
/** 获取减签人员标签 */
function getDeleteSignUserLabel(task: any): string {
const deptName = task?.assigneeUser?.deptName || task?.ownerUser?.deptName
const nickname = task?.assigneeUser?.nickname || task?.ownerUser?.nickname
return `${nickname} ( 所属部门:${deptName} )`
}
/** 获取可减签的任务列表 */
async function loadDeleteSignTaskList() {
let childTasks = []
// 从 props 中获取子任务数据
if (props.children) {
try {
childTasks = JSON.parse(decodeURIComponent(props.children))
} catch (parseError) {
console.error('[delete-sign] 解析子任务数据失败:', parseError)
}
}
// 提示没有子任务数据
if (childTasks.length === 0) {
toast.show('没有可减签的任务')
return
}
taskOptions.value = childTasks.map(task => ({
id: task.id,
label: getDeleteSignUserLabel(task),
}))
}
/** 提交操作 */
async function handleSubmit() {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
formLoading.value = true
try {
await signDeleteTask({
id: formData.deleteSignTaskId,
reason: formData.reason,
})
toast.success('减签成功')
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`,
})
}, 500)
} finally {
formLoading.value = false
}
}
/** 页面加载时,获取可减签任务列表 */
onMounted(() => {
/** 初始化校验 */
if (!props.taskId || !props.processInstanceId) {
toast.show('参数错误')
return
}
loadDeleteSignTaskList()
})
</script>

View File

@@ -0,0 +1,147 @@
<template>
<view class="yd-page-container pb-[80rpx]">
<!-- 顶部导航栏 -->
<wd-navbar
title="审批详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 区域流程信息基本信息 -->
<view class="relative mx-24rpx mt-24rpx overflow-hidden rounded-16rpx bg-white">
<!-- 审批状态图标盖章效果 -->
<image
v-if="processInstance?.status !== undefined"
:src="getStatusIcon(processInstance?.status)"
class="absolute right-20rpx top-20rpx z-10 h-144rpx w-144rpx"
mode="aspectFit"
/>
<view class="p-24rpx">
<!-- 标题 -->
<view class="mb-16rpx pr-160rpx">
<text class="text-32rpx text-[#333] font-bold">{{ processInstance?.name }}</text>
</view>
<!-- 发起人信息 -->
<view class="flex items-center">
<view class="mr-12rpx h-64rpx w-64rpx flex items-center justify-center rounded-full bg-[#1890ff] text-white">
{{ processInstance?.startUser?.nickname?.[0] || '?' }}
</view>
<view>
<text class="text-28rpx text-[#333]">{{ processInstance?.startUser?.nickname }}</text>
<text v-if="processInstance?.startUser?.deptName" class="ml-8rpx text-24rpx text-[#999]">
{{ processInstance?.startUser?.deptName }}
</text>
</view>
</view>
<!-- 提交时间 -->
<view class="mt-16rpx text-24rpx text-[#999]">
提交于 {{ formatDateTime(processInstance?.startTime) }}
</view>
</view>
</view>
<!-- 区域审批详情表单 -->
<FormDetail :process-definition="processDefinition" :process-instance="processInstance" />
<!-- 区域审批进度 -->
<view class="mx-24rpx mt-24rpx rounded-16rpx bg-white">
<view class="p-24rpx">
<view class="mb-16rpx flex">
<text class="text-28rpx text-[#333] font-bold">审批进度</text>
</view>
<!-- 流程时间线 -->
<ProcessInstanceTimeline :activity-nodes="activityNodes" />
</view>
</view>
<!-- TODO 待开发区域流程评论 -->
<!-- 区域底部操作栏 -->
<ProcessInstanceOperationButton ref="operationButtonRef" />
</view>
</template>
<script lang="ts" setup>
import type { ApprovalNodeInfo, ProcessDefinition, ProcessInstance } from '@/api/bpm/processInstance'
import type { Task } from '@/api/bpm/task'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getApprovalDetail } from '@/api/bpm/processInstance'
import { getTaskListByProcessInstanceId } from '@/api/bpm/task'
import { navigateBackPlus } from '@/utils'
import { BpmProcessInstanceStatus } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import FormDetail from './components/form-detail.vue'
import ProcessInstanceOperationButton from './components/operation-button.vue'
import ProcessInstanceTimeline from './components/time-line.vue'
const props = defineProps<{
id: string // 流程实例的编号
taskId?: string // 任务编号
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const processInstance = ref<ProcessInstance>()
const processDefinition = ref<ProcessDefinition>()
const tasks = ref<Task[]>([])
const activityNodes = ref<ApprovalNodeInfo[]>([]) // 审批节点信息
const operationButtonRef = ref() // 操作按钮组件 ref
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages/bpm/index')
}
/** 获取状态图标 */
function getStatusIcon(status?: number): string {
// 状态映射: 1-审批中, 2-审批通过, 3-审批不通过, 4-已取消. -1 未开始不会出现
const iconMap: Record<number, string> = {
[BpmProcessInstanceStatus.RUNNING]: '/static/my-icons/bpm/bpm-running.svg', // 待审批
[BpmProcessInstanceStatus.APPROVE]: '/static/my-icons/bpm/bpm-approve.svg', // 审批通过
[BpmProcessInstanceStatus.REJECT]: '/static/my-icons/bpm/bpm-reject.svg', // 审批不通过
[BpmProcessInstanceStatus.CANCEL]: '/static/my-icons/bpm/bpm-cancel.svg', // 已取消
}
return iconMap[status ?? 1]
}
/** 加载流程实例 */
async function loadProcessInstance() {
const data = await getApprovalDetail({
processInstanceId: props.id,
taskId: props.taskId,
})
if (!data || !data.processInstance) {
toast.show('查询不到审批详情信息')
return
}
processInstance.value = data.processInstance
processDefinition.value = data.processDefinition
// 获取审批节点,显示 Timeline 的数据
activityNodes.value = data.activityNodes
operationButtonRef.value?.init(data.processInstance, data.todoTask)
}
/** 加载任务列表 */
async function loadTasks() {
tasks.value = await getTaskListByProcessInstanceId(props.id)
}
/** 初始化 */
onMounted(async () => {
if (!props.id) {
toast.show('参数错误')
return
}
await Promise.all([loadProcessInstance(), loadTasks()])
})
</script>

View File

@@ -0,0 +1,126 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="取消流程"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 操作表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 友情提醒 -->
<view class="mb-24rpx border border-[#ffd591] rounded-16rpx bg-[#fff7e6] p-24rpx">
<view class="mb-12rpx flex items-center">
<wd-icon name="warning" color="#faad14" size="32rpx" />
<text class="ml-12rpx text-28rpx text-[#faad14] font-bold">友情提醒</text>
</view>
<text class="text-26rpx text-[#666]">取消后该审批流程将自动结束</text>
</view>
<!-- 取消理由 -->
<wd-textarea
v-model="formData.cancelReason"
prop="cancelReason"
label="取消理由:"
label-width="180rpx"
placeholder="请输入取消理由"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="mt-48rpx">
<wd-button
type="primary"
block
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit"
>
确认取消
</wd-button>
</view>
</wd-form>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import { computed, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { cancelProcessInstanceByStartUser } from '@/api/bpm/processInstance'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
processInstanceId: string
taskId?: string
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const processInstanceId = computed(() => props.processInstanceId)
const taskId = computed(() => props.taskId)
const toast = useToast()
const formLoading = ref(false)
const formData = reactive({
cancelReason: '',
})
const formRules = {
cancelReason: [
{ required: true, message: '取消理由不能为空' },
],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
const backUrl = taskId.value
? `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`
: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}`
navigateBackPlus(backUrl)
}
/** 提交操作 */
async function handleSubmit() {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
formLoading.value = true
try {
await cancelProcessInstanceByStartUser(
processInstanceId.value,
formData.cancelReason,
)
toast.success('流程取消成功')
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}`,
})
}, 500)
} finally {
formLoading.value = false
}
}
/** 页面加载时 */
onMounted(() => {
/** 初始化校验 */
if (!props.processInstanceId) {
toast.show('参数错误')
}
})
</script>

View File

@@ -0,0 +1,143 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="isDelegate ? '委派任务' : '转办任务'"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 操作表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 用户选择 -->
<UserPicker
v-model="formData.userId"
prop="userId"
type="radio"
:label="`${isDelegate ? '接收人' : '新审批人'}`"
:placeholder="`请选择${isDelegate ? '接收人' : '新审批人'}`"
/>
<!-- 审批意见 -->
<wd-textarea
v-model="formData.reason"
prop="reason"
label="审批意见:"
label-width="180rpx"
placeholder="请输入审批意见"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="mt-48rpx">
<wd-button
type="primary"
block
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit"
>
{{ isDelegate ? '委派' : '转办' }}
</wd-button>
</view>
</wd-form>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import { computed, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { delegateTask, transferTask } from '@/api/bpm/task'
import UserPicker from '@/components/system-select/user-picker.vue'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
processInstanceId: string
taskId: string
type: string // 'delegate' 或 'transfer'
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const taskId = computed(() => props.taskId)
const processInstanceId = computed(() => props.processInstanceId)
const operationType = computed(() => props.type || 'transfer') // 默认转办
const isDelegate = computed(() => operationType.value === 'delegate')
const toast = useToast()
const formLoading = ref(false)
const formData = reactive({
userId: undefined as number | undefined,
reason: '',
})
const formRules = {
userId: [
{ required: true, message: `请选择${isDelegate.value ? '接收人' : '新审批人'}` },
],
reason: [
{ required: true, message: '审批意见不能为空' },
],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
}
/** 提交操作 */
async function handleSubmit() {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
formLoading.value = true
try {
const data = {
id: taskId.value as string,
reason: formData.reason,
}
if (isDelegate.value) {
await delegateTask({
...data,
delegateUserId: String(formData.userId),
})
} else {
await transferTask({
...data,
assigneeUserId: String(formData.userId),
})
}
toast.success(`${isDelegate.value ? '委派' : '转办'}成功`)
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`,
})
}, 500)
} finally {
formLoading.value = false
}
}
/** 页面加载时 */
onMounted(() => {
/** 初始化校验 */
if (!props.taskId || !props.processInstanceId) {
toast.show('参数错误')
}
})
</script>

View File

@@ -0,0 +1,140 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="退回任务"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 操作表单 -->
<view class="p-24rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<!-- 退回节点选择 -->
<wd-picker
v-model="formData.targetActivityId"
label="退回节点:"
prop="targetActivityId"
:columns="activityOptions"
value-key="taskDefinitionKey"
label-key="name"
placeholder="请选择退回节点"
/>
<!-- 退回原因 -->
<wd-textarea
v-model="formData.reason"
prop="reason"
label="退回原因:"
label-width="180rpx"
placeholder="请输入退回原因"
:maxlength="500"
show-word-limit
clearable
/>
</wd-cell-group>
<!-- 提交按钮 -->
<view class="mt-48rpx">
<wd-button
type="primary"
block
:loading="formLoading"
:disabled="formLoading"
@click="handleSubmit"
>
退回
</wd-button>
</view>
</wd-form>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getTaskListByReturn, returnTask } from '@/api/bpm/task'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{
processInstanceId: string
taskId: string
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const taskId = computed(() => props.taskId)
const processInstanceId = computed(() => props.processInstanceId)
const toast = useToast()
const formLoading = ref(false)
const activityOptions = ref<any[]>([])
const formData = reactive({
targetActivityId: '',
reason: '',
})
const formRules = {
targetActivityId: [
{ required: true, message: '退回节点不能为空' },
],
reason: [
{ required: true, message: '退回原因不能为空' },
],
}
const formRef = ref<FormInstance>()
/** 返回上一页 */
function handleBack() {
navigateBackPlus(`/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`)
}
/** 获取可退回的节点列表 */
async function loadReturnTaskList() {
const result = await getTaskListByReturn(taskId.value)
activityOptions.value = result
}
/** 提交操作 */
async function handleSubmit() {
if (formLoading.value) {
return
}
const { valid } = await formRef.value!.validate()
if (!valid) {
return
}
formLoading.value = true
try {
await returnTask({
id: taskId.value as string,
targetTaskDefinitionKey: formData.targetActivityId,
reason: formData.reason,
})
toast.success('退回成功')
setTimeout(() => {
uni.redirectTo({
url: `/pages-bpm/processInstance/detail/index?id=${processInstanceId.value}&taskId=${taskId.value}`,
})
}, 500)
} finally {
formLoading.value = false
}
}
/** 页面加载时获取可退回节点列表 */
onMounted(() => {
/** 初始化校验 */
if (!props.taskId || !props.processInstanceId) {
toast.show('参数错误')
return
}
loadReturnTaskList()
})
</script>

View File

@@ -0,0 +1,222 @@
<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>
<UserPicker
v-model="formData.startUserId"
type="radio"
placeholder="请选择发起人"
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
流程名称
</view>
<wd-input
v-model="formData.name"
placeholder="请输入流程名称"
clearable
/>
</view>
<view v-if="processDefinitionList.length > 0" class="yd-search-form-item">
<view class="yd-search-form-label">
所属流程
</view>
<wd-picker
v-model="formData.processDefinitionId"
:columns="processDefinitionList"
label-key="name"
value-key="id"
label=""
/>
</view>
<!-- 流程分类 -->
<view v-if="categoryList.length > 0" class="yd-search-form-item">
<view class="yd-search-form-label">
流程分类
</view>
<wd-picker
v-model="formData.category"
:columns="categoryList"
label-key="name"
value-key="code"
label=""
/>
</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-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 type { Category } from '@/api/bpm/category'
import type { ProcessDefinition } from '@/api/bpm/definition'
import { computed, onMounted, reactive, ref } from 'vue'
import { getCategorySimpleList } from '@/api/bpm/category'
import { getProcessDefinitionList } from '@/api/bpm/definition'
import UserPicker from '@/components/system-select/user-picker.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({
startUserId: undefined as number | undefined, // 发起人
name: undefined as string | undefined, // 流程名称
processDefinitionId: undefined as string | undefined, // 所属流程
category: undefined as string | undefined, // 流程分类
status: -1, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined], // 发起时间
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`名称:${formData.name}`)
}
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])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索流程实例'
})
const categoryList = ref<Category[]>([])
const processDefinitionList = ref<ProcessDefinition[]>([])
// 时间选择器状态
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
}
/** 获取流程分类列表 */
async function getCategoryList() {
try {
categoryList.value = await getCategorySimpleList()
} catch (error) {
console.error('获取流程分类失败:', error)
}
}
/** 获取流程定义列表 */
async function getProcessDefinitions() {
try {
processDefinitionList.value = await getProcessDefinitionList({ suspensionState: 1 })
} catch (error) {
console.error('获取流程定义失败:', error)
}
}
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
status: formData.status === -1 ? undefined : formData.status,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.startUserId = undefined
formData.name = undefined
formData.processDefinitionId = undefined
formData.category = undefined
formData.status = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
/** 初始化 */
onMounted(() => {
getCategoryList()
getProcessDefinitions()
})
</script>

View File

@@ -0,0 +1,231 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="流程管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 流程实例列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="mr-16rpx flex-1">
<view class="line-clamp-1 text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<view class="mt-8rpx text-24rpx text-[#999]">
{{ item.categoryName || '-' }}
</view>
</view>
<DictTag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center">
<view class="mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-20rpx text-white">
{{ item.startUser?.nickname?.[0] || '?' }}
</view>
<view class="flex-1">
<view class="text-28rpx text-[#333]">
{{ item.startUser?.nickname || '-' }}
</view>
<view class="text-24rpx text-[#999]">
{{ item.startUser?.deptName || '-' }}
</view>
</view>
</view>
<view class="mb-12rpx rounded-8rpx bg-[#f7f8f9] p-16rpx">
<view class="mb-8rpx flex items-center justify-between text-26rpx">
<text class="text-[#999]">发起时间</text>
<text class="text-[#333]">{{ formatDateTime(item.startTime) }}</text>
</view>
<view v-if="item.endTime" class="flex items-center justify-between text-26rpx">
<text class="text-[#999]">结束时间</text>
<text class="text-[#333]">{{ formatDateTime(item.endTime) }}</text>
</view>
</view>
<view v-if="item.tasks && item.tasks.length > 0" class="mb-12rpx">
<view class="mb-8rpx text-26rpx text-[#999]">
当前审批任务
</view>
<view class="flex flex-wrap gap-8rpx">
<wd-tag
v-for="task in item.tasks"
:key="task.id"
type="primary"
plain
@click.stop="handleTaskDetail(item, task)"
>
{{ task.name }}
</wd-tag>
</view>
</view>
<view
v-if="item.status === BpmProcessInstanceStatus.RUNNING"
class="flex items-center justify-end border-t border-[#f0f0f0] -mt-8"
>
<wd-button size="small" type="error" plain @click.stop="handleCancel(item)">
取消流程
</wd-button>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无流程实例" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import type { ProcessInstance } from '@/api/bpm/processInstance'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import {
cancelProcessInstanceByAdmin,
getProcessInstanceManagerPage,
} from '@/api/bpm/processInstance'
import DictTag from '@/components/dict-tag/dict-tag.vue'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
// 流程实例状态枚举
const BpmProcessInstanceStatus = {
RUNNING: 1, // 进行中
APPROVE: 2, // 审批通过
REJECT: 3, // 审批不通过
CANCEL: 4, // 已取消
}
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const total = ref(0)
const list = ref<(ProcessInstance & { tasks?: { id: string, name: string }[] })[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询流程实例列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getProcessInstanceManagerPage(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 handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 查看详情 */
function handleDetail(item: ProcessInstance) {
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${item.id}` })
}
/** 查看任务详情 */
function handleTaskDetail(row: ProcessInstance, task: { id: string, name: string }) {
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${row.id}&taskId=${task.id}` })
}
/** 取消流程实例 */
function handleCancel(item: ProcessInstance) {
uni.showModal({
title: '取消流程',
editable: true,
placeholderText: '请输入取消原因',
success: async (res) => {
if (!res.confirm) {
return
}
const reason = res.content?.trim()
if (!reason) {
toast.error('请输入取消原因')
return
}
try {
await cancelProcessInstanceByAdmin(item.id, reason)
toast.success('取消成功')
// 刷新列表
queryParams.value.pageNo = 1
list.value = []
await getList()
} catch (error) {
console.error('取消流程失败:', error)
}
},
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,128 @@
<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-input
v-model="formData.name"
placeholder="请输入任务名称"
clearable
/>
</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-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 { getNavbarHeight } from '@/utils'
import { formatDate, formatDateRange } from '@/utils/date'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false)
const formData = reactive({
name: undefined as string | undefined, // 任务名称
createTime: [undefined, undefined] as [number | undefined, number | undefined], // 创建时间
})
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`名称:${formData.name}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
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', {
...formData,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,166 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="任务管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 任务列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="mr-16rpx flex-1">
<view class="line-clamp-1 text-32rpx text-[#333] font-semibold">
{{ item.processInstance?.name || '-' }}
</view>
<view class="mt-8rpx text-24rpx text-[#999]">
当前任务{{ item.name }}
</view>
</view>
<DictTag :type="DICT_TYPE.BPM_TASK_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center">
<view class="mr-8rpx h-48rpx w-48rpx flex items-center justify-center rounded-full bg-[#1890ff] text-20rpx text-white">
{{ item.processInstance?.startUser?.nickname?.[0] || '?' }}
</view>
<view class="flex-1">
<view class="text-28rpx text-[#333]">
发起人{{ item.processInstance?.startUser?.nickname || '-' }}
</view>
<view class="text-24rpx text-[#999]">
审批人{{ item.assigneeUser?.nickname || '-' }}
</view>
</view>
</view>
<view class="rounded-8rpx bg-[#f7f8f9] p-16rpx">
<view class="mb-8rpx flex items-center justify-between text-26rpx">
<text class="text-[#999]">任务开始时间</text>
<text class="text-[#333]">{{ formatDateTime(item.createTime) }}</text>
</view>
<view v-if="item.endTime" class="mb-8rpx flex items-center justify-between text-26rpx">
<text class="text-[#999]">任务结束时间</text>
<text class="text-[#333]">{{ formatDateTime(item.endTime) }}</text>
</view>
<view v-if="item.reason" class="flex items-center justify-between text-26rpx">
<text class="text-[#999]">审批建议</text>
<text class="line-clamp-1 ml-16rpx flex-1 text-right text-[#333]">{{ item.reason }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无任务" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Task } from '@/api/bpm/task'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getTaskManagerPage } from '@/api/bpm/task'
import DictTag from '@/components/dict-tag/dict-tag.vue'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const total = ref(0)
const list = ref<Task[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询任务列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getTaskManagerPage(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 handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 查看详情(历史) */
function handleDetail(item: Task) {
if (item.processInstance?.id) {
uni.navigateTo({ url: `/pages-bpm/processInstance/detail/index?id=${item.processInstance.id}` })
}
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,153 @@
<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-input
v-model="formData.name"
placeholder="请输入组名"
clearable
/>
</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.COMMON_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-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({
name: undefined as string | undefined,
status: -1, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined],
})
// 时间范围选择器状态
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
}
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`组名:${formData.name}`)
}
if (formData.status !== -1) {
conditions.push(`状态:${getDictLabel(DICT_TYPE.COMMON_STATUS, formData.status)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`创建时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索用户分组'
})
/** 搜索 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
status: formData.status === -1 ? undefined : formData.status,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置 */
function handleReset() {
formData.name = undefined
formData.status = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>

View File

@@ -0,0 +1,155 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="用户分组详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="编号" :value="formData?.id" />
<wd-cell title="组名" :value="formData?.name" />
<wd-cell title="描述" :value="formData?.description || '-'" />
<wd-cell title="成员">
<view class="flex flex-wrap gap-8rpx justify-end">
<wd-tag
v-for="userId in (formData?.userIds || [])"
:key="userId"
type="primary"
plain
>
{{ getUserNickname(userId) }}
</wd-tag>
<text v-if="!formData?.userIds?.length">-</text>
</view>
</wd-cell>
<wd-cell title="状态">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="formData?.status" />
</wd-cell>
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime)" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['bpm:user-group:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['bpm:user-group:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { UserGroup } from '@/api/bpm/user-group'
import type { SimpleUser } from '@/api/system/user'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteUserGroup, getUserGroup } from '@/api/bpm/user-group'
import { getSimpleUserList } from '@/api/system/user'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<UserGroup>()
const deleting = ref(false)
const userList = ref<SimpleUser[]>([])
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/user-group/index')
}
/** 获取用户昵称 */
function getUserNickname(userId: number) {
const user = userList.value.find(u => u.id === userId)
return user?.nickname || userId
}
/** 加载用户列表 */
async function loadUserList() {
userList.value = await getSimpleUserList()
}
/** 加载用户分组详情 */
async function getDetail() {
if (!props.id) {
return
}
try {
toast.loading('加载中...')
formData.value = await getUserGroup(props.id)
} finally {
toast.close()
}
}
/** 编辑用户分组 */
function handleEdit() {
uni.navigateTo({
url: `/pages-bpm/user-group/form/index?id=${props.id}`,
})
}
/** 删除用户分组 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该用户分组吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteUserGroup(props.id)
toast.success('删除成功')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
loadUserList()
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,151 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="组名"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入组名"
/>
<wd-textarea
v-model="formData.description"
label="描述"
label-width="180rpx"
prop="description"
clearable
placeholder="请输入描述"
/>
<UserPicker
ref="userPickerRef"
v-model="formData.userIds"
label="成员"
type="checkbox"
placeholder="请选择成员"
/>
<wd-cell title="状态" title-width="180rpx" prop="status" center>
<wd-radio-group v-model="formData.status" shape="button">
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { UserGroup } from '@/api/bpm/user-group'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createUserGroup, getUserGroup, updateUserGroup } from '@/api/bpm/user-group'
import { UserPicker } from '@/components/system-select'
import { getIntDictOptions } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { CommonStatusEnum, DICT_TYPE } from '@/utils/constants'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑用户分组' : '新增用户分组')
const formLoading = ref(false)
const formData = ref<UserGroup>({
id: undefined,
name: '',
description: '',
userIds: [],
status: CommonStatusEnum.ENABLE,
remark: '',
})
const formRules = {
name: [{ required: true, message: '组名不能为空' }],
userIds: [{ required: true, message: '成员不能为空' }],
status: [{ required: true, message: '状态不能为空' }],
}
const formRef = ref<FormInstance>()
const userPickerRef = ref()
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-bpm/user-group/index')
}
/** 加载用户分组详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getUserGroup(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateUserGroup(formData.value)
toast.success('修改成功')
} else {
await createUserGroup(formData.value)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,190 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="用户分组管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 用户分组列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="item.status" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">描述</text>
<text class="min-w-0 flex-1 truncate">{{ item.description || '-' }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx shrink-0 text-[#999]">成员</text>
<view class="min-w-0 flex flex-1 flex-wrap gap-8rpx">
<wd-tag
v-for="userId in (item.userIds || []).slice(0, 3)"
:key="userId"
type="primary"
plain
>
{{ getUserNickname(userId) }}
</wd-tag>
<wd-tag v-if="(item.userIds || []).length > 3" type="info" plain>
+{{ (item.userIds || []).length - 3 }}
</wd-tag>
</view>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间</text>
<text class="line-clamp-1">{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无用户分组数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['bpm:user-group:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { UserGroup } from '@/api/bpm/user-group'
import type { SimpleUser } from '@/api/system/user'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getUserGroupPage } from '@/api/bpm/user-group'
import { getSimpleUserList } from '@/api/system/user'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<UserGroup[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
const userList = ref<SimpleUser[]>([])
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 获取用户昵称 */
function getUserNickname(userId: number) {
const user = userList.value.find(u => u.id === userId)
return user?.nickname || userId
}
/** 加载用户列表 */
async function loadUserList() {
userList.value = await getSimpleUserList()
}
/** 查询用户分组列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getUserGroupPage(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 handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
}
/** 新增用户分组 */
function handleAdd() {
uni.navigateTo({
url: '/pages-bpm/user-group/form/index',
})
}
/** 查看详情 */
function handleDetail(item: UserGroup) {
uni.navigateTo({
url: `/pages-bpm/user-group/detail/index?id=${item.id}`,
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
loadUserList()
getList()
})
</script>
<style lang="scss" scoped>
</style>

Some files were not shown because too many files have changed in this diff Show More