Browse Source

Merge branch 'wangzeyan'

master
王泽彦 10 months ago
parent
commit
66730f9a68
  1. 16
      admin/package-lock.json
  2. 3
      admin/package.json
  3. 103
      admin/src/app/api/school_approval/config.ts
  4. 41
      admin/src/app/api/school_approval/process.ts
  5. 84
      admin/src/app/lang/zh-cn/approval.ts
  6. 638
      admin/src/app/views/school_approval/config/index.vue
  7. 552
      admin/src/app/views/school_approval/process/index.vue
  8. 45
      admin/src/router/modules/approval.ts
  9. 4
      admin/src/router/routers.ts
  10. 7
      admin/src/utils/common.ts
  11. 60
      admin/src/utils/request.ts
  12. 6654
      admin/yarn.lock
  13. 192
      niucloud/app/adminapi/controller/school_approval/Config.php
  14. 154
      niucloud/app/adminapi/controller/school_approval/Process.php
  15. 48
      niucloud/app/adminapi/route/school_approval.php
  16. 53
      niucloud/app/model/school_approval/SchoolApprovalConfig.php
  17. 66
      niucloud/app/model/school_approval/SchoolApprovalConfigNode.php
  18. 66
      niucloud/app/model/school_approval/SchoolApprovalParticipants.php
  19. 60
      niucloud/app/model/school_approval/SchoolApprovalProcess.php
  20. 96
      niucloud/app/model/school_approval_config.sql
  21. 185
      niucloud/app/service/school_approval/SchoolApprovalConfigService.php
  22. 289
      niucloud/app/service/school_approval/SchoolApprovalProcessService.php

16
admin/package-lock.json

@ -36,7 +36,8 @@
"vue-router": "4.1.6",
"vue-ueditor-wrap": "^3.0.8",
"vue-web-terminal": "3.2.2",
"vue3-video-play": "1.3.1-beta.6"
"vue3-video-play": "1.3.1-beta.6",
"vuedraggable": "^2.24.3"
},
"devDependencies": {
"@tailwindcss/line-clamp": "0.4.2",
@ -6368,6 +6369,19 @@
"vue": "^3.2.2"
}
},
"node_modules/vuedraggable": {
"version": "2.24.3",
"resolved": "https://registry.npmmirror.com/vuedraggable/-/vuedraggable-2.24.3.tgz",
"integrity": "sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==",
"dependencies": {
"sortablejs": "1.10.2"
}
},
"node_modules/vuedraggable/node_modules/sortablejs": {
"version": "1.10.2",
"resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.10.2.tgz",
"integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A=="
},
"node_modules/webpack-sources": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/webpack-sources/-/webpack-sources-3.2.3.tgz",

3
admin/package.json

@ -40,7 +40,8 @@
"vue-router": "4.1.6",
"vue-ueditor-wrap": "^3.0.8",
"vue-web-terminal": "3.2.2",
"vue3-video-play": "1.3.1-beta.6"
"vue3-video-play": "1.3.1-beta.6",
"vuedraggable": "^2.24.3"
},
"devDependencies": {
"@tailwindcss/line-clamp": "0.4.2",

103
admin/src/app/api/school_approval/config.ts

@ -0,0 +1,103 @@
import request from '@/utils/request'
/**
*
* @param params
*/
export function getConfigList(params?: Record<string, any>) {
try {
return request.get('/school_approval/config/lists', { params })
} catch (error) {
console.error('Error in getConfigList:', error)
return Promise.reject(error)
}
}
/**
*
* @param params
*/
export function getConfigInfo(params: { id: number }) {
if (!params || typeof params.id !== 'number') {
console.error('Invalid params for getConfigInfo', params)
return Promise.reject(new Error('Invalid params'))
}
try {
return request.get('/school_approval/config/info', { params })
} catch (error) {
console.error('Error in getConfigInfo:', error)
return Promise.reject(error)
}
}
/**
*
* @param params
*/
export function addConfig(params: any) {
if (!params) {
console.error('Invalid params for addConfig', params)
return Promise.reject(new Error('Invalid params'))
}
try {
// 清理params中的不必要属性,防止序列化问题
const sanitizedParams = JSON.parse(JSON.stringify(params))
return request.post('/school_approval/config/add', sanitizedParams)
} catch (error) {
console.error('Error in addConfig:', error)
return Promise.reject(error)
}
}
/**
*
* @param params
*/
export function editConfig(params: any) {
if (!params || !params.id) {
console.error('Invalid params for editConfig', params)
return Promise.reject(new Error('Invalid params'))
}
try {
// 清理params中的不必要属性,防止序列化问题
const sanitizedParams = JSON.parse(JSON.stringify(params))
return request.post('/school_approval/config/edit', sanitizedParams)
} catch (error) {
console.error('Error in editConfig:', error)
return Promise.reject(error)
}
}
/**
*
* @param params
*/
export function deleteConfig(params: { id: number }) {
if (!params || typeof params.id !== 'number') {
console.error('Invalid params for deleteConfig', params)
return Promise.reject(new Error('Invalid params'))
}
try {
return request.post('/school_approval/config/delete', params)
} catch (error) {
console.error('Error in deleteConfig:', error)
return Promise.reject(error)
}
}
/**
*
* @param params
*/
export function changeConfigStatus(params: { id: number; status: number }) {
if (!params || typeof params.id !== 'number' || typeof params.status !== 'number') {
console.error('Invalid params for changeConfigStatus', params)
return Promise.reject(new Error('Invalid params'))
}
try {
return request.post('/school_approval/config/changeStatus', params)
} catch (error) {
console.error('Error in changeConfigStatus:', error)
return Promise.reject(error)
}
}

41
admin/src/app/api/school_approval/process.ts

@ -0,0 +1,41 @@
import request from '@/utils/request'
/**
*
* @param params
*/
export function getProcessList(params?: Record<string, any>) {
return request.get({ url: '/school_approval/process/lists', params })
}
/**
*
* @param params
*/
export function getProcessInfo(params: { id: number }) {
return request.get({ url: '/school_approval/process/info', params })
}
/**
*
* @param params
*/
export function createProcess(params: any) {
return request.post({ url: '/school_approval/process/create', data: params })
}
/**
*
* @param params
*/
export function approveProcess(params: { process_id: number; status: string; remarks?: string }) {
return request.post({ url: '/school_approval/process/approve', data: params })
}
/**
*
* @param params
*/
export function cancelProcess(params: { process_id: number }) {
return request.post({ url: '/school_approval/process/cancel', data: params })
}

84
admin/src/app/lang/zh-cn/approval.ts

@ -0,0 +1,84 @@
export default {
menu: {
title: '审批管理',
config: '审批流配置',
process: '审批流程'
},
config: {
id: 'ID',
configName: '配置名称',
configNamePlaceholder: '请输入配置名称',
configNameRequired: '请输入配置名称',
description: '配置描述',
descriptionPlaceholder: '请输入配置描述',
status: '状态',
statusPlaceholder: '请选择状态',
enabled: '启用',
disabled: '禁用',
createdAt: '创建时间',
nodes: '审批节点',
addNode: '添加节点',
noNodes: '暂无审批节点,请点击添加',
nodeLabel: '节点 {index}',
nodeName: '节点名称',
nodeNamePlaceholder: '请输入节点名称',
nodeNameRequired: '请输入节点名称',
approverType: '审批人类型',
approverTypePlaceholder: '请选择审批人类型',
approverTypeRequired: '请选择审批人类型',
approverIds: '审批人',
approverIdsPlaceholder: '请选择审批人',
approverIdsRequired: '请选择审批人',
signType: '审批类型',
orSign: '或签(一人通过即可)',
andSign: '会签(需全部通过)',
searchPlaceholder: '请输入配置名称',
nodesRequired: '请至少添加一个审批节点',
add: '添加审批流配置',
edit: '编辑审批流配置',
detail: '审批流配置详情',
nodeList: '审批节点列表',
enableSuccess: '启用成功',
disableSuccess: '禁用成功'
},
process: {
id: 'ID',
processName: '流程名称',
processNamePlaceholder: '请输入流程名称',
processNameRequired: '请输入流程名称',
applicantId: '申请人',
applicationTime: '申请时间',
currentApproverId: '当前审批人',
approvalStatus: '审批状态',
approvalTime: '审批时间',
remarks: '备注',
remarksPlaceholder: '请输入备注',
searchPlaceholder: '请输入流程名称',
statusPlaceholder: '请选择审批状态',
pending: '待审批',
approved: '已批准',
rejected: '已拒绝',
all: '全部',
myCreate: '我的申请',
myApproval: '待我审批',
create: '发起审批',
createSuccess: '发起审批成功',
configId: '审批配置',
configIdPlaceholder: '请选择审批配置',
configIdRequired: '请选择审批配置',
approve: '审批',
approveSuccess: '审批成功',
status: '审批结果',
statusRequired: '请选择审批结果',
cancel: '撤销',
cancelSuccess: '撤销成功',
confirmCancel: '确定要撤销该审批流程吗?',
detail: '审批流程详情',
participants: '审批参与人',
participantLabel: '参与人 {index}',
participantId: '参与人ID',
sequence: '审批顺序',
orSign: '或签',
andSign: '会签'
}
}

638
admin/src/app/views/school_approval/config/index.vue

@ -0,0 +1,638 @@
<template>
<div class="approval-config-container">
<el-card class="box-card !border-none" shadow="never">
<div class="flex justify-between items-center">
<div>
<el-button type="primary" @click="handleAdd">
<icon name="add" class="mr-5px" />
添加
</el-button>
</div>
<div class="flex items-center">
<el-input
v-model="state.searchParams.config_name"
class="w-200px mr-15px"
placeholder="请输入配置名称"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select
v-model="state.searchParams.status"
class="w-150px mr-15px"
placeholder="请选择状态"
clearable
@change="handleSearch"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">
<icon name="search" class="mr-5px" />
搜索
</el-button>
<el-button @click="handleReset">
<icon name="refresh-right" class="mr-5px" />
重置
</el-button>
</div>
</div>
<el-table
v-loading="state.loading"
class="mt-15px"
:data="state.configList"
:header-cell-style="{ background: '#fafafa', color: '#606266' }"
>
<el-table-column label="ID" prop="id" width="80" />
<el-table-column label="配置名称" prop="config_name" min-width="180" />
<el-table-column label="配置描述" prop="description" min-width="200" show-overflow-tooltip />
<el-table-column label="状态" prop="status" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="created_at" width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
编辑
</el-button>
<el-button type="primary" link @click="handleDetail(row)">
详情
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-15px">
<el-pagination
v-model:current-page="state.searchParams.page"
v-model:page-size="state.searchParams.limit"
:page-sizes="[10, 20, 50, 100]"
:total="state.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="state.dialog.visible"
:title="state.dialog.title"
width="800px"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<el-form
ref="formRef"
:model="state.dialog.form"
:rules="state.dialog.rules"
label-width="120px"
>
<el-form-item label="配置名称" prop="config_name">
<el-input v-model="state.dialog.form.config_name" placeholder="请输入配置名称" />
</el-form-item>
<el-form-item label="配置描述" prop="description">
<el-input
v-model="state.dialog.form.description"
type="textarea"
:rows="3"
placeholder="请输入配置描述"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch
v-model="state.dialog.form.status"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
<el-form-item label="审批节点" prop="nodes">
<div class="mb-10px">
<el-button type="primary" @click="handleAddNode">
<icon name="add" class="mr-5px" />
添加节点
</el-button>
</div>
<div v-if="state.dialog.form.nodes.length === 0" class="text-gray-400 text-center py-20px border border-dashed rounded">
暂无审批节点请点击添加
</div>
<div v-else class="node-list">
<div
v-for="(element, index) in state.dialog.form.nodes"
:key="index"
class="node-item p-15px mb-15px border border-gray-200 rounded"
>
<div class="flex justify-between items-center mb-10px">
<div class="flex items-center">
<icon name="rank" class="drag-handle mr-5px cursor-move" />
<span class="font-bold">节点 {{ index + 1 }}</span>
</div>
<el-button type="danger" link @click="handleRemoveNode(index)">
删除
</el-button>
</div>
<div class="flex items-center mb-10px">
<span class="w-100px">节点名称</span>
<el-input
v-model="element.node_name"
placeholder="请输入节点名称"
/>
</div>
<div class="flex items-center mb-10px">
<span class="w-100px">审批人类型</span>
<el-select
v-model="element.approver_type"
class="flex-1"
placeholder="请选择审批人类型"
>
<el-option label="指定用户" value="user" />
<el-option label="指定角色" value="role" />
<el-option label="指定部门" value="department" />
</el-select>
</div>
<div class="flex items-center mb-10px">
<span class="w-100px">审批人</span>
<el-select
v-model="element.approver_ids"
class="flex-1"
multiple
placeholder="请选择审批人"
>
<!-- 这里根据 approver_type 不同显示不同的选项 -->
<template v-if="element.approver_type === 'user'">
<el-option
v-for="item in state.userOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</template>
<template v-else-if="element.approver_type === 'role'">
<el-option
v-for="item in state.roleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</template>
<template v-else-if="element.approver_type === 'department'">
<el-option
v-for="item in state.departmentOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</template>
</el-select>
</div>
<div class="flex items-center">
<span class="w-100px">审批类型</span>
<el-radio-group v-model="element.sign_type">
<el-radio label="or_sign">或签一人通过即可</el-radio>
<el-radio label="and_sign">会签需全部通过</el-radio>
</el-radio-group>
</div>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="state.dialog.visible = false">取消</el-button>
<el-button type="primary" :loading="state.dialog.loading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog
v-model="state.detailDialog.visible"
title="审批流配置详情"
width="800px"
:destroy-on-close="true"
>
<el-descriptions :column="1" border>
<el-descriptions-item label="ID">
{{ state.detailDialog.info?.id || '-' }}
</el-descriptions-item>
<el-descriptions-item label="配置名称">
{{ state.detailDialog.info?.config_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="配置描述">
{{ state.detailDialog.info?.description || '-' }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="state.detailDialog.info?.status ? 'success' : 'danger'">
{{ state.detailDialog.info?.status ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ state.detailDialog.info?.created_at || '-' }}
</el-descriptions-item>
</el-descriptions>
<div class="mt-20px">
<div class="font-bold text-16px mb-10px">审批节点列表</div>
<el-timeline>
<el-timeline-item
v-for="(node, index) in state.detailDialog.info?.nodes || []"
:key="index"
:type="getNodeType(node.sign_type)"
:color="getNodeColor(node.sign_type)"
>
<div class="font-bold mb-5px">{{ node.node_name }}</div>
<div class="text-gray-500">
审批人类型{{ getApproverTypeText(node.approver_type) }}
</div>
<div class="text-gray-500">
审批人{{ node.approver_ids }}
</div>
<div class="text-gray-500">
审批类型{{ node.sign_type === 'or_sign' ? '或签' : '会签' }}
</div>
</el-timeline-item>
</el-timeline>
</div>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, FormInstance } from 'element-plus'
import { getConfigList, getConfigInfo, addConfig, editConfig, deleteConfig, changeConfigStatus } from '@/app/api/school_approval/config'
// draggable使
// import draggable from 'vuedraggable'
// i18n
// import { useI18n } from 'vue-i18n'
// const { t } = useI18n()
//
const formRef = ref<FormInstance>()
//
interface DetailInfo {
id?: number;
config_name?: string;
description?: string;
status?: number;
created_at?: string;
nodes?: Array<{
node_name: string;
approver_type: string;
approver_ids: string;
sign_type: string;
}>;
}
//
interface ApprovalNode {
key: string;
node_name: string;
approver_type: string;
approver_ids: string[];
sign_type: string;
}
//
const state = reactive({
loading: false,
configList: [],
total: 0,
searchParams: {
page: 1,
limit: 10,
config_name: '',
status: ''
},
// API
userOptions: [
{ label: '用户1', value: '1' },
{ label: '用户2', value: '2' },
{ label: '用户3', value: '3' }
],
roleOptions: [
{ label: '角色1', value: '1' },
{ label: '角色2', value: '2' }
],
departmentOptions: [
{ label: '部门1', value: '1' },
{ label: '部门2', value: '2' }
],
dialog: {
visible: false,
title: '',
loading: false,
type: 'add', // add or edit
form: {
id: 0,
config_name: '',
description: '',
status: 1,
nodes: [] as ApprovalNode[]
},
rules: {
config_name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' }
],
nodes: [
{ required: true, validator: validateNodes, trigger: 'change' }
]
}
},
detailDialog: {
visible: false,
info: {} as DetailInfo
}
})
//
function validateNodes(rule: any, value: any, callback: any) {
if (!value || value.length === 0) {
callback(new Error('请至少添加一个审批节点'))
} else {
for (const node of value) {
if (!node.node_name) {
callback(new Error('请输入节点名称'))
return
}
if (!node.approver_type) {
callback(new Error('请选择审批人类型'))
return
}
if (!node.approver_ids || node.approver_ids.length === 0) {
callback(new Error('请选择审批人'))
return
}
}
callback()
}
}
//
async function getList() {
state.loading = true
try {
const res = await getConfigList(state.searchParams)
state.configList = res.data.list
state.total = res.data.count
} catch (error) {
console.error(error)
} finally {
state.loading = false
}
}
//
function handleSearch() {
state.searchParams.page = 1
getList()
}
//
function handleReset() {
state.searchParams = {
page: 1,
limit: 10,
config_name: '',
status: ''
}
getList()
}
//
function handleCurrentChange(page: number) {
state.searchParams.page = page
getList()
}
//
function handleSizeChange(size: number) {
state.searchParams.limit = size
state.searchParams.page = 1
getList()
}
//
function handleAdd() {
state.dialog.type = 'add'
state.dialog.title = '添加审批流配置'
state.dialog.form = {
id: 0,
config_name: '',
description: '',
status: 1,
nodes: []
}
state.dialog.visible = true
}
//
async function handleEdit(row: any) {
state.dialog.type = 'edit'
state.dialog.title = '编辑审批流配置'
state.dialog.loading = true
try {
const res = await getConfigInfo({ id: row.id })
state.dialog.form = { ...res.data }
// key
if (state.dialog.form.nodes && Array.isArray(state.dialog.form.nodes)) {
state.dialog.form.nodes = state.dialog.form.nodes.map((node: any, index: number) => {
// approver_idssplit
const approverIds = typeof node.approver_ids === 'string'
? node.approver_ids.split(',')
: (Array.isArray(node.approver_ids) ? node.approver_ids : []);
return {
...node,
key: `node_${index}_${Date.now()}`,
approver_ids: approverIds
}
})
} else {
state.dialog.form.nodes = []
}
state.dialog.visible = true
} catch (error) {
console.error(error)
ElMessage.error('获取数据失败')
} finally {
state.dialog.loading = false
}
}
//
async function handleDetail(row: any) {
try {
const res = await getConfigInfo({ id: row.id })
state.detailDialog.info = res.data
state.detailDialog.visible = true
} catch (error) {
console.error(error)
}
}
//
function handleDelete(row: any) {
ElMessageBox.confirm('确定要删除此项吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
await deleteConfig({ id: row.id })
ElMessage.success('删除成功')
getList()
} catch (error) {
console.error(error)
}
})
.catch(() => {})
}
//
async function handleStatusChange(row: any) {
try {
await changeConfigStatus({ id: row.id, status: row.status })
ElMessage.success(
row.status
? '启用成功'
: '禁用成功'
)
} catch (error) {
console.error(error)
row.status = row.status ? 0 : 1 //
}
}
//
function handleAddNode() {
state.dialog.form.nodes.push({
key: `node_${state.dialog.form.nodes.length}_${Date.now()}`,
node_name: '',
approver_type: 'user',
approver_ids: [],
sign_type: 'or_sign'
})
}
//
function handleRemoveNode(index: number) {
state.dialog.form.nodes.splice(index, 1)
}
//
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
state.dialog.loading = true
try {
//
const formData = { ...state.dialog.form }
//
formData.nodes = formData.nodes.map((node: any, index: number) => {
// keyAPI
const { key, ...nodeWithoutKey } = node;
// approver_idsjoin
const approverIds = Array.isArray(node.approver_ids)
? node.approver_ids.join(',')
: (typeof node.approver_ids === 'string' ? node.approver_ids : '');
return {
...nodeWithoutKey,
node_name: node.node_name || `节点${index + 1}`, //
approver_ids: approverIds,
sign_type: node.sign_type || 'or_sign' //
}
});
//
console.log('提交的表单数据:', JSON.stringify(formData));
if (state.dialog.type === 'add') {
await addConfig(formData)
ElMessage.success('添加成功')
} else {
await editConfig(formData)
ElMessage.success('编辑成功')
}
state.dialog.visible = false
getList()
} catch (error) {
console.error('提交表单时出错:', error)
ElMessage.error('提交失败,请检查表单数据或网络连接')
} finally {
state.dialog.loading = false
}
})
}
//
function getNodeType(signType: string) {
return signType === 'or_sign' ? 'primary' : 'success'
}
//
function getNodeColor(signType: string) {
return signType === 'or_sign' ? '#409EFF' : '#67C23A'
}
//
function getApproverTypeText(type: string) {
const map: Record<string, string> = {
user: '指定用户',
role: '指定角色',
department: '指定部门'
}
return map[type] || type
}
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
.node-item {
background-color: #f9f9f9;
transition: all 0.3s;
&:hover {
background-color: #f2f2f2;
}
}
.ghost {
opacity: 0.5;
background: #c8ebfb;
}
.drag-handle {
cursor: move;
color: #909399;
&:hover {
color: #409eff;
}
}
</style>

552
admin/src/app/views/school_approval/process/index.vue

@ -0,0 +1,552 @@
<template>
<div class="approval-process-container">
<el-card class="box-card !border-none" shadow="never">
<div class="flex justify-between items-center">
<div>
<el-button type="primary" @click="handleCreate">
<icon name="add" class="mr-5px" />
{{ $t('approval.process.create') }}
</el-button>
</div>
<div class="flex items-center">
<el-input
v-model="state.searchParams.process_name"
class="w-200px mr-15px"
:placeholder="$t('approval.process.searchPlaceholder')"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select
v-model="state.searchParams.approval_status"
class="w-150px mr-15px"
:placeholder="$t('approval.process.statusPlaceholder')"
clearable
@change="handleSearch"
>
<el-option :label="$t('approval.process.pending')" value="pending" />
<el-option :label="$t('approval.process.approved')" value="approved" />
<el-option :label="$t('approval.process.rejected')" value="rejected" />
</el-select>
<el-button type="primary" @click="handleSearch">
<icon name="search" class="mr-5px" />
{{ $t('common.search') }}
</el-button>
<el-button @click="handleReset">
<icon name="refresh-right" class="mr-5px" />
{{ $t('common.reset') }}
</el-button>
</div>
</div>
<el-tabs v-model="state.activeTab" class="mt-15px" @tab-click="handleTabChange">
<el-tab-pane :label="$t('approval.process.all')" name="all" />
<el-tab-pane :label="$t('approval.process.myCreate')" name="myCreate" />
<el-tab-pane :label="$t('approval.process.myApproval')" name="myApproval" />
</el-tabs>
<el-table
v-loading="state.loading"
:data="state.processList"
:header-cell-style="{ background: '#fafafa', color: '#606266' }"
>
<el-table-column :label="$t('approval.process.id')" prop="id" width="80" />
<el-table-column :label="$t('approval.process.processName')" prop="process_name" min-width="180" />
<el-table-column :label="$t('approval.process.applicantId')" prop="applicant_id" width="100" />
<el-table-column :label="$t('approval.process.applicationTime')" prop="application_time" width="180" />
<el-table-column :label="$t('approval.process.currentApproverId')" prop="current_approver_id" width="100" />
<el-table-column :label="$t('approval.process.approvalStatus')" prop="approval_status" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.approval_status)">
{{ getStatusText(row.approval_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('common.action')" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleDetail(row)">
{{ $t('common.detail') }}
</el-button>
<el-button
v-if="row.approval_status === 'pending' && row.applicant_id === state.userInfo.uid"
type="danger"
link
@click="handleCancel(row)"
>
{{ $t('approval.process.cancel') }}
</el-button>
<el-button
v-if="row.approval_status === 'pending' && row.current_approver_id === state.userInfo.uid"
type="success"
link
@click="handleApprove(row)"
>
{{ $t('approval.process.approve') }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-15px">
<el-pagination
v-model:current-page="state.searchParams.page"
v-model:page-size="state.searchParams.limit"
:page-sizes="[10, 20, 50, 100]"
:total="state.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 创建审批弹窗 -->
<el-dialog
v-model="state.createDialog.visible"
:title="$t('approval.process.create')"
width="600px"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<el-form
ref="createFormRef"
:model="state.createDialog.form"
:rules="state.createDialog.rules"
label-width="120px"
>
<el-form-item :label="$t('approval.process.processName')" prop="process_name">
<el-input
v-model="state.createDialog.form.process_name"
:placeholder="$t('approval.process.processNamePlaceholder')"
/>
</el-form-item>
<el-form-item :label="$t('approval.process.configId')" prop="config_id">
<el-select
v-model="state.createDialog.form.config_id"
class="w-full"
:placeholder="$t('approval.process.configIdPlaceholder')"
>
<el-option
v-for="item in state.configOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t('approval.process.remarks')" prop="remarks">
<el-input
v-model="state.createDialog.form.remarks"
type="textarea"
:rows="3"
:placeholder="$t('approval.process.remarksPlaceholder')"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="state.createDialog.visible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="state.createDialog.loading" @click="handleCreateSubmit">
{{ $t('common.confirm') }}
</el-button>
</template>
</el-dialog>
<!-- 审批弹窗 -->
<el-dialog
v-model="state.approveDialog.visible"
:title="$t('approval.process.approve')"
width="500px"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<el-form
ref="approveFormRef"
:model="state.approveDialog.form"
:rules="state.approveDialog.rules"
label-width="80px"
>
<el-form-item :label="$t('approval.process.status')" prop="status">
<el-radio-group v-model="state.approveDialog.form.status">
<el-radio label="approved">{{ $t('approval.process.approved') }}</el-radio>
<el-radio label="rejected">{{ $t('approval.process.rejected') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="$t('approval.process.remarks')" prop="remarks">
<el-input
v-model="state.approveDialog.form.remarks"
type="textarea"
:rows="3"
:placeholder="$t('approval.process.remarksPlaceholder')"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="state.approveDialog.visible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="state.approveDialog.loading" @click="handleApproveSubmit">
{{ $t('common.confirm') }}
</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog
v-model="state.detailDialog.visible"
:title="$t('approval.process.detail')"
width="800px"
:destroy-on-close="true"
>
<el-descriptions :column="1" border>
<el-descriptions-item :label="$t('approval.process.id')">
{{ state.detailDialog.info.id }}
</el-descriptions-item>
<el-descriptions-item :label="$t('approval.process.processName')">
{{ state.detailDialog.info.process_name }}
</el-descriptions-item>
<el-descriptions-item :label="$t('approval.process.applicantId')">
{{ state.detailDialog.info.applicant_id }}
</el-descriptions-item>
<el-descriptions-item :label="$t('approval.process.applicationTime')">
{{ state.detailDialog.info.application_time }}
</el-descriptions-item>
<el-descriptions-item :label="$t('approval.process.currentApproverId')">
{{ state.detailDialog.info.current_approver_id }}
</el-descriptions-item>
<el-descriptions-item :label="$t('approval.process.approvalStatus')">
<el-tag :type="getStatusType(state.detailDialog.info.approval_status)">
{{ getStatusText(state.detailDialog.info.approval_status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="$t('approval.process.approvalTime')">
{{ state.detailDialog.info.approval_time || '-' }}
</el-descriptions-item>
<el-descriptions-item :label="$t('approval.process.remarks')">
{{ state.detailDialog.info.remarks || '-' }}
</el-descriptions-item>
</el-descriptions>
<div class="mt-20px">
<div class="font-bold text-16px mb-10px">{{ $t('approval.process.participants') }}</div>
<el-timeline>
<el-timeline-item
v-for="(node, index) in state.detailDialog.info.participants"
:key="index"
:type="getNodeType(node.status)"
:color="getNodeColor(node.status)"
>
<div class="font-bold mb-5px">
{{ $t('approval.process.participantLabel', { index: index + 1 }) }}
</div>
<div class="text-gray-500">
{{ $t('approval.process.participantId') }}{{ node.participant_id }}
</div>
<div class="text-gray-500">
{{ $t('approval.process.sequence') }}{{ node.sequence }}
</div>
<div class="text-gray-500">
{{ $t('approval.process.status') }}{{ getStatusText(node.status) }}
</div>
<div class="text-gray-500">
{{ $t('approval.process.signType') }}{{ node.sign_type === 'or_sign' ? $t('approval.process.orSign') : $t('approval.process.andSign') }}
</div>
<div class="text-gray-500">
{{ $t('approval.process.remarks') }}{{ node.remarks || '-' }}
</div>
</el-timeline-item>
</el-timeline>
</div>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, FormInstance } from 'element-plus'
import { getProcessList, getProcessInfo, createProcess, approveProcess, cancelProcess } from '@/app/api/school_approval/process'
import { getConfigList } from '@/app/api/school_approval/config'
import { useI18n } from 'vue-i18n'
import { useUserInfo } from '@/stores/userInfo'
const { t } = useI18n()
const userInfo = useUserInfo()
//
const createFormRef = ref<FormInstance>()
const approveFormRef = ref<FormInstance>()
//
const state = reactive({
loading: false,
processList: [],
total: 0,
activeTab: 'all',
userInfo: userInfo,
searchParams: {
page: 1,
limit: 10,
process_name: '',
approval_status: '',
applicant_id: 0,
approver_id: 0
},
configOptions: [], //
createDialog: {
visible: false,
loading: false,
form: {
process_name: '',
config_id: '',
remarks: ''
},
rules: {
process_name: [
{ required: true, message: t('approval.process.processNameRequired'), trigger: 'blur' }
],
config_id: [
{ required: true, message: t('approval.process.configIdRequired'), trigger: 'change' }
]
}
},
approveDialog: {
visible: false,
loading: false,
processId: 0,
form: {
status: 'approved',
remarks: ''
},
rules: {
status: [
{ required: true, message: t('approval.process.statusRequired'), trigger: 'change' }
]
}
},
detailDialog: {
visible: false,
info: {}
}
})
//
async function getList() {
state.loading = true
try {
const res = await getProcessList(state.searchParams)
state.processList = res.data.list
state.total = res.data.count
} catch (error) {
console.error(error)
} finally {
state.loading = false
}
}
//
function handleSearch() {
state.searchParams.page = 1
getList()
}
//
function handleReset() {
state.searchParams = {
page: 1,
limit: 10,
process_name: '',
approval_status: '',
applicant_id: 0,
approver_id: 0
}
state.activeTab = 'all'
getList()
}
//
function handleCurrentChange(page: number) {
state.searchParams.page = page
getList()
}
//
function handleSizeChange(size: number) {
state.searchParams.limit = size
state.searchParams.page = 1
getList()
}
//
function handleTabChange() {
//
state.searchParams.applicant_id = 0
state.searchParams.approver_id = 0
//
if (state.activeTab === 'myCreate') {
state.searchParams.applicant_id = state.userInfo.uid
} else if (state.activeTab === 'myApproval') {
state.searchParams.approver_id = state.userInfo.uid
}
state.searchParams.page = 1
getList()
}
//
async function getConfigOptions() {
try {
const res = await getConfigList({ status: 1 })
state.configOptions = res.data.list.map((item: any) => {
return {
label: item.config_name,
value: item.id
}
})
} catch (error) {
console.error(error)
}
}
//
function handleCreate() {
state.createDialog.form = {
process_name: '',
config_id: '',
remarks: ''
}
state.createDialog.visible = true
}
//
async function handleCreateSubmit() {
if (!createFormRef.value) return
await createFormRef.value.validate(async (valid) => {
if (!valid) return
state.createDialog.loading = true
try {
await createProcess(state.createDialog.form)
ElMessage.success(t('approval.process.createSuccess'))
state.createDialog.visible = false
getList()
} catch (error) {
console.error(error)
} finally {
state.createDialog.loading = false
}
})
}
//
function handleApprove(row: any) {
state.approveDialog.processId = row.id
state.approveDialog.form = {
status: 'approved',
remarks: ''
}
state.approveDialog.visible = true
}
//
async function handleApproveSubmit() {
if (!approveFormRef.value) return
await approveFormRef.value.validate(async (valid) => {
if (!valid) return
state.approveDialog.loading = true
try {
await approveProcess({
process_id: state.approveDialog.processId,
status: state.approveDialog.form.status,
remarks: state.approveDialog.form.remarks
})
ElMessage.success(t('approval.process.approveSuccess'))
state.approveDialog.visible = false
getList()
} catch (error) {
console.error(error)
} finally {
state.approveDialog.loading = false
}
})
}
//
function handleCancel(row: any) {
ElMessageBox.confirm(t('approval.process.confirmCancel'), t('common.warning'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
.then(async () => {
try {
await cancelProcess({ process_id: row.id })
ElMessage.success(t('approval.process.cancelSuccess'))
getList()
} catch (error) {
console.error(error)
}
})
.catch(() => {})
}
//
async function handleDetail(row: any) {
try {
const res = await getProcessInfo({ id: row.id })
state.detailDialog.info = res.data
state.detailDialog.visible = true
} catch (error) {
console.error(error)
}
}
//
function getStatusType(status: string) {
const map: Record<string, string> = {
pending: 'warning',
approved: 'success',
rejected: 'danger'
}
return map[status] || 'info'
}
//
function getStatusText(status: string) {
const map: Record<string, string> = {
pending: t('approval.process.pending'),
approved: t('approval.process.approved'),
rejected: t('approval.process.rejected')
}
return map[status] || status
}
//
function getNodeType(status: string) {
const map: Record<string, string> = {
pending: 'warning',
approved: 'success',
rejected: 'danger'
}
return map[status] || 'info'
}
//
function getNodeColor(status: string) {
const map: Record<string, string> = {
pending: '#E6A23C',
approved: '#67C23A',
rejected: '#F56C6C'
}
return map[status] || '#909399'
}
onMounted(() => {
getList()
getConfigOptions()
})
</script>
<style lang="scss" scoped>
//
</style>

45
admin/src/router/modules/approval.ts

@ -0,0 +1,45 @@
import { RouteRecordRaw } from 'vue-router'
import Default from '@/layout/index.vue'
/**
* @param name , ,
* @param meta
* @param redirect , 访,
* @param meta.title
* @param meta.icon
* @param meta.keepAlive
* @param meta.sort
*
* */
const routes: Array<RouteRecordRaw> = [
{
path: '/admin/school_approval',
name: 'SchoolApproval',
component: Default,
redirect: '/admin/school_approval/config',
meta: {
title: 'approval.menu.title',
sort: 100
},
children: [
{
path: 'config',
name: 'SchoolApprovalConfig',
component: () => import('@/app/views/school_approval/config/index.vue'),
meta: {
title: 'approval.menu.config'
}
},
{
path: 'process',
name: 'SchoolApprovalProcess',
component: () => import('@/app/views/school_approval/process/index.vue'),
meta: {
title: 'approval.menu.process'
}
}
]
}
]
export default routes

4
admin/src/router/routers.ts

@ -2,12 +2,16 @@ import { RouteRecordRaw, RouterView } from 'vue-router'
import Default from '@/layout/index.vue'
import Decorate from '@/layout/decorate/index.vue'
// 导入模块路由
import approvalRoutes from './modules/approval'
// 静态路由
export const STATIC_ROUTES: Array<RouteRecordRaw> = [
{
path: '/:pathMatch(.*)*',
component: () => import('@/app/views/error/404.vue'),
},
...approvalRoutes
]
// 免登录路由

7
admin/src/utils/common.ts

@ -118,7 +118,8 @@ export function debounce(fn: (args?: any) => any, delay: number = 300) {
* @param str
* @returns
*/
export function isUrl(str: string): boolean {
export function isUrl(str: any): boolean {
if (!str || typeof str !== 'string') return false;
return str.indexOf('http://') != -1 || str.indexOf('https://') != -1
}
@ -189,7 +190,7 @@ const isArray = (value: any) => {
* @returns {*}
*/
export function deepClone(obj: object) {
// 对常见的“非”值,直接返回原来值
// 对常见的"非"值,直接返回原来值
if ([null, undefined, NaN, false].includes(obj)) return obj
if (typeof obj !== 'object' && typeof obj !== 'function') {
// 原始类型直接返回
@ -332,7 +333,7 @@ export function filterSpecial(event: any) {
''
)
event.target.value = event.target.value.replace(
/[`~!@#$%^&*()_\-+=<>?:"{}|,.\/;'\\[\]·~!@#¥%……&*()——\-+={}|《》?:“”【】、;‘’,。、]/g,
/[`~!@#$%^&*()_\-+=<>?:"{}|,.\/;'\\[\]·~!@#¥%……&*()——\-+={}|《》?:""【】、;'',。、]/g,
''
)
}

60
admin/src/utils/request.ts

@ -31,10 +31,11 @@ class Request {
constructor() {
this.instance = axios.create({
baseURL:
import.meta.env.VITE_APP_BASE_URL.substr(-1) == '/'
? import.meta.env.VITE_APP_BASE_URL
: `${import.meta.env.VITE_APP_BASE_URL}/`,
baseURL: typeof import.meta.env.VITE_APP_BASE_URL === 'string' && import.meta.env.VITE_APP_BASE_URL.length > 0
? (import.meta.env.VITE_APP_BASE_URL.substr(-1) == '/'
? import.meta.env.VITE_APP_BASE_URL
: `${import.meta.env.VITE_APP_BASE_URL}/`)
: '/',
timeout: 0,
headers: {
'Content-Type': 'application/json',
@ -97,12 +98,22 @@ class Request {
url: string,
config?: RequestConfig
): Promise<R> {
return this.instance.get(url, config)
if (!url || typeof url !== 'string') {
console.error('Invalid URL for GET request:', url)
return Promise.reject(new Error('Invalid URL'))
}
try {
return this.instance.get(url, config)
} catch (error) {
console.error('Error in GET request:', error)
return Promise.reject(error)
}
}
/**
* get请求
* post请求
* @param url
* @param data
* @param config
* @returns
*/
@ -111,7 +122,16 @@ class Request {
data?: D,
config?: RequestConfig
): Promise<R> {
return this.instance.post(url, data, config)
if (!url || typeof url !== 'string') {
console.error('Invalid URL for POST request:', url)
return Promise.reject(new Error('Invalid URL'))
}
try {
return this.instance.post(url, data, config)
} catch (error) {
console.error('Error in POST request:', error)
return Promise.reject(error)
}
}
/**
@ -161,9 +181,16 @@ class Request {
errMessage = t('axios.403')
break
case 404:
const baseURL = isUrl(err.response.config.baseURL)
? err.response.config.baseURL
: `${location.origin}${err.response.config.baseURL}`
let baseURL = '';
try {
baseURL = err.response.config.baseURL && typeof err.response.config.baseURL === 'string'
? (isUrl(err.response.config.baseURL)
? err.response.config.baseURL
: `${location.origin}${err.response.config.baseURL}`)
: location.origin;
} catch (e) {
baseURL = location.origin;
}
errMessage = baseURL + t('axios.baseUrlError')
break
case 405:
@ -197,9 +224,16 @@ class Request {
}
err.message.includes('timeout') && (errMessage = t('axios.timeout'))
if (err.code == 'ERR_NETWORK') {
const baseURL = isUrl(err.config.baseURL)
? err.config.baseURL
: `${location.origin}${err.config.baseURL}`
let baseURL = '';
try {
baseURL = err.config.baseURL && typeof err.config.baseURL === 'string'
? (isUrl(err.config.baseURL)
? err.config.baseURL
: `${location.origin}${err.config.baseURL}`)
: location.origin;
} catch (e) {
baseURL = location.origin;
}
errMessage = baseURL + t('axios.baseUrlError')
}
errMessage &&

6654
admin/yarn.lock

File diff suppressed because it is too large

192
niucloud/app/adminapi/controller/school_approval/Config.php

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\school_approval;
use app\adminapi\controller\BaseAdminApi;
use app\service\school_approval\SchoolApprovalConfigService;
use think\facade\Request;
/**
* 审批流配置控制器
* Class Config
* @package app\adminapi\controller\school_approval
*/
class Config extends BaseAdminApi
{
/**
* @var SchoolApprovalConfigService
*/
protected $service;
public function initialize()
{
parent::initialize();
$this->service = new SchoolApprovalConfigService();
}
/**
* 获取审批流配置列表
*/
public function lists()
{
$page = input('page', 1);
$limit = input('limit', 10);
$status = input('status', '');
$where = [];
if ($status !== '') {
$where[] = ['status', '=', intval($status)];
}
$config_name = input('config_name', '');
if (!empty($config_name)) {
$where[] = ['config_name', 'like', "%{$config_name}%"];
}
$data = $this->service->getList($where, $page, $limit);
return success($data);
}
/**
* 获取审批流配置详情
*/
public function info()
{
$id = input('id', 0);
if (empty($id)) {
return error('参数错误');
}
$info = $this->service->getInfo($id);
if (empty($info)) {
return error('审批流配置不存在');
}
return success($info);
}
/**
* 添加审批流配置
*/
public function add()
{
$data = Request::only(['config_name', 'description', 'status', 'nodes']);
// 验证参数
if (empty($data['config_name'])) {
return error('配置名称不能为空');
}
if (empty($data['nodes']) || !is_array($data['nodes'])) {
return error('至少需要添加一个审批节点');
}
// 验证节点数据
foreach ($data['nodes'] as $node) {
if (empty($node['node_name'])) {
return error('节点名称不能为空');
}
if (empty($node['approver_type'])) {
return error('审批人类型不能为空');
}
if (empty($node['approver_ids'])) {
return error('审批人不能为空');
}
}
// 设置创建人ID
$data['creator_id'] = $this->user_info['uid'];
try {
$config_id = $this->service->add($data);
return success(['id' => $config_id]);
} catch (\Exception $e) {
return error($e->getMessage());
}
}
/**
* 编辑审批流配置
*/
public function edit()
{
$data = Request::only(['id', 'config_name', 'description', 'status', 'nodes']);
// 验证参数
if (empty($data['id'])) {
return error('参数错误');
}
if (empty($data['config_name'])) {
return error('配置名称不能为空');
}
if (empty($data['nodes']) || !is_array($data['nodes'])) {
return error('至少需要添加一个审批节点');
}
// 验证节点数据
foreach ($data['nodes'] as $node) {
if (empty($node['node_name'])) {
return error('节点名称不能为空');
}
if (empty($node['approver_type'])) {
return error('审批人类型不能为空');
}
if (empty($node['approver_ids'])) {
return error('审批人不能为空');
}
}
try {
$result = $this->service->edit($data);
return success($result);
} catch (\Exception $e) {
return error($e->getMessage());
}
}
/**
* 删除审批流配置
*/
public function delete()
{
$id = input('id', 0);
if (empty($id)) {
return error('参数错误');
}
try {
$result = $this->service->delete($id);
return success($result);
} catch (\Exception $e) {
return error($e->getMessage());
}
}
/**
* 修改状态
*/
public function changeStatus()
{
$id = input('id', 0);
$status = input('status', 0);
if (empty($id)) {
return error('参数错误');
}
try {
$result = $this->service->changeStatus($id, $status);
return success($result);
} catch (\Exception $e) {
return error($e->getMessage());
}
}
}

154
niucloud/app/adminapi/controller/school_approval/Process.php

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\school_approval;
use app\adminapi\controller\BaseAdminApi;
use app\service\school_approval\SchoolApprovalProcessService;
use think\facade\Request;
/**
* 审批流程控制器
* Class Process
* @package app\adminapi\controller\school_approval
*/
class Process extends BaseAdminApi
{
/**
* @var SchoolApprovalProcessService
*/
protected $service;
public function initialize()
{
parent::initialize();
$this->service = new SchoolApprovalProcessService();
}
/**
* 获取审批流程列表
*/
public function lists()
{
$page = input('page', 1);
$limit = input('limit', 10);
$status = input('approval_status', '');
$where = [];
if ($status !== '') {
$where[] = ['approval_status', '=', $status];
}
$process_name = input('process_name', '');
if (!empty($process_name)) {
$where[] = ['process_name', 'like', "%{$process_name}%"];
}
// 我发起的审批
$applicant_id = input('applicant_id', 0);
if (!empty($applicant_id)) {
$where[] = ['applicant_id', '=', $applicant_id];
}
// 待我审批的
$approver_id = input('approver_id', 0);
if (!empty($approver_id)) {
$where[] = ['current_approver_id', '=', $approver_id];
$where[] = ['approval_status', '=', 'pending'];
}
$data = $this->service->getList($where, $page, $limit);
return success($data);
}
/**
* 获取审批流程详情
*/
public function info()
{
$id = input('id', 0);
if (empty($id)) {
return error('参数错误');
}
$info = $this->service->getInfo($id);
if (empty($info)) {
return error('审批流程不存在');
}
return success($info);
}
/**
* 创建审批流程
*/
public function create()
{
$data = Request::only(['process_name', 'remarks']);
$config_id = input('config_id', 0);
// 验证参数
if (empty($data['process_name'])) {
return error('流程名称不能为空');
}
if (empty($config_id)) {
return error('请选择审批流配置');
}
// 设置申请人ID
$data['applicant_id'] = $this->user_info['uid'];
try {
$process_id = $this->service->create($data, $config_id);
return success(['id' => $process_id]);
} catch (\Exception $e) {
return error($e->getMessage());
}
}
/**
* 审批
*/
public function approve()
{
$process_id = input('process_id', 0);
$status = input('status', '');
$remarks = input('remarks', '');
if (empty($process_id)) {
return error('参数错误');
}
if (empty($status) || !in_array($status, ['approved', 'rejected'])) {
return error('请选择审批结果');
}
try {
$result = $this->service->approve($process_id, $this->user_info['uid'], $status, $remarks);
return success($result);
} catch (\Exception $e) {
return error($e->getMessage());
}
}
/**
* 撤销审批流程
*/
public function cancel()
{
$process_id = input('process_id', 0);
if (empty($process_id)) {
return error('参数错误');
}
try {
$result = $this->service->cancel($process_id, $this->user_info['uid']);
return success($result);
} catch (\Exception $e) {
return error($e->getMessage());
}
}
}

48
niucloud/app/adminapi/route/school_approval.php

@ -0,0 +1,48 @@
<?php
// +----------------------------------------------------------------------
// | 审批流路由
// +----------------------------------------------------------------------
use think\facade\Route;
// 审批流配置
Route::group('school_approval/config', function () {
// 审批流配置列表
Route::get('lists', 'school_approval.Config/lists');
// 审批流配置详情
Route::get('info', 'school_approval.Config/info');
// 添加审批流配置
Route::post('add', 'school_approval.Config/add');
// 编辑审批流配置
Route::post('edit', 'school_approval.Config/edit');
// 删除审批流配置
Route::post('delete', 'school_approval.Config/delete');
// 修改状态
Route::post('changeStatus', 'school_approval.Config/changeStatus');
})->middleware(
[
app\adminapi\middleware\AdminCheckToken::class,
app\adminapi\middleware\AdminCheckRole::class,
app\adminapi\middleware\AdminLog::class
]
);
// 审批流程
Route::group('school_approval/process', function () {
// 审批流程列表
Route::get('lists', 'school_approval.Process/lists');
// 审批流程详情
Route::get('info', 'school_approval.Process/info');
// 创建审批流程
Route::post('create', 'school_approval.Process/create');
// 审批
Route::post('approve', 'school_approval.Process/approve');
// 撤销审批流程
Route::post('cancel', 'school_approval.Process/cancel');
})->middleware(
[
app\adminapi\middleware\AdminCheckToken::class,
app\adminapi\middleware\AdminCheckRole::class,
app\adminapi\middleware\AdminLog::class
]
);

53
niucloud/app/model/school_approval/SchoolApprovalConfig.php

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace app\model\school_approval;
use think\Model;
/**
* 审批流配置模型
* Class SchoolApprovalConfig
* @package app\model\school_approval
*/
class SchoolApprovalConfig extends Model
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 模型名称
* @var string
*/
protected $name = 'school_approval_config';
/**
* 自动写入时间戳
* @var bool
*/
protected $autoWriteTimestamp = true;
/**
* 创建时间字段
* @var string
*/
protected $createTime = 'created_at';
/**
* 更新时间字段
* @var string
*/
protected $updateTime = 'updated_at';
/**
* 关联审批流配置节点
* @return \think\model\relation\HasMany
*/
public function nodes()
{
return $this->hasMany(SchoolApprovalConfigNode::class, 'config_id', 'id')->order('sequence', 'asc');
}
}

66
niucloud/app/model/school_approval/SchoolApprovalConfigNode.php

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace app\model\school_approval;
use think\Model;
/**
* 审批流配置节点模型
* Class SchoolApprovalConfigNode
* @package app\model\school_approval
*/
class SchoolApprovalConfigNode extends Model
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 模型名称
* @var string
*/
protected $name = 'school_approval_config_node';
/**
* 自动写入时间戳
* @var bool
*/
protected $autoWriteTimestamp = true;
/**
* 创建时间字段
* @var string
*/
protected $createTime = 'created_at';
/**
* 更新时间字段
* @var string
*/
protected $updateTime = 'updated_at';
/**
* 审批人类型
*/
const APPROVER_TYPE_USER = 'user'; // 指定用户
const APPROVER_TYPE_ROLE = 'role'; // 指定角色
const APPROVER_TYPE_DEPARTMENT = 'department'; // 指定部门
/**
* 签署类型
*/
const SIGN_TYPE_OR = 'or_sign'; // 或签(一人通过即可)
const SIGN_TYPE_AND = 'and_sign'; // 会签(需全部通过)
/**
* 关联审批流配置
* @return \think\model\relation\BelongsTo
*/
public function config()
{
return $this->belongsTo(SchoolApprovalConfig::class, 'config_id', 'id');
}
}

66
niucloud/app/model/school_approval/SchoolApprovalParticipants.php

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace app\model\school_approval;
use think\Model;
/**
* 审批参与人模型
* Class SchoolApprovalParticipants
* @package app\model\school_approval
*/
class SchoolApprovalParticipants extends Model
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 模型名称
* @var string
*/
protected $name = 'school_approval_participants';
/**
* 自动写入时间戳
* @var bool
*/
protected $autoWriteTimestamp = true;
/**
* 创建时间字段
* @var string
*/
protected $createTime = 'created_at';
/**
* 更新时间字段
* @var string
*/
protected $updateTime = 'updated_at';
/**
* 审批状态
*/
const STATUS_PENDING = 'pending'; // 待审批
const STATUS_APPROVED = 'approved'; // 已批准
const STATUS_REJECTED = 'rejected'; // 已拒绝
/**
* 签署类型
*/
const SIGN_TYPE_OR = 'or_sign'; // 或签(一人通过即可)
const SIGN_TYPE_AND = 'and_sign'; // 会签(需全部通过)
/**
* 关联审批流程
* @return \think\model\relation\BelongsTo
*/
public function process()
{
return $this->belongsTo(SchoolApprovalProcess::class, 'process_id', 'id');
}
}

60
niucloud/app/model/school_approval/SchoolApprovalProcess.php

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\model\school_approval;
use think\Model;
/**
* 审批流程模型
* Class SchoolApprovalProcess
* @package app\model\school_approval
*/
class SchoolApprovalProcess extends Model
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 模型名称
* @var string
*/
protected $name = 'school_approval_process';
/**
* 自动写入时间戳
* @var bool
*/
protected $autoWriteTimestamp = true;
/**
* 创建时间字段
* @var string
*/
protected $createTime = 'created_at';
/**
* 更新时间字段
* @var string
*/
protected $updateTime = 'updated_at';
/**
* 审批状态
*/
const STATUS_PENDING = 'pending'; // 待审批
const STATUS_APPROVED = 'approved'; // 已批准
const STATUS_REJECTED = 'rejected'; // 已拒绝
/**
* 关联审批参与人
* @return \think\model\relation\HasMany
*/
public function participants()
{
return $this->hasMany(SchoolApprovalParticipants::class, 'process_id', 'id')->order('sequence', 'asc');
}
}

96
niucloud/app/model/school_approval_config.sql

@ -0,0 +1,96 @@
-- ========================
-- 数据库结构增强脚本(MySQL)
-- ========================
-- 1. 会计期间有效性校验
ALTER TABLE financial_accounting_periods
ADD COLUMN status ENUM('active', 'closed', 'locked') DEFAULT 'active' COMMENT '期间状态',
ADD COLUMN validation_date DATE COMMENT '最后验证日期';
-- 2. 预算表(支持多版本)
CREATE TABLE financial_budgets (
budget_id INT AUTO_INCREMENT PRIMARY KEY COMMENT '预算ID',
company_id INT NOT NULL COMMENT '公司ID',
period_id INT NOT NULL COMMENT '会计期间ID',
account_id INT NOT NULL COMMENT '科目ID',
budget_version INT DEFAULT 1 COMMENT '版本号',
planned_amount DECIMAL(18,2) COMMENT '计划金额',
approved_date DATE COMMENT '审批日期',
FOREIGN KEY (company_id) REFERENCES financial_companies(company_id),
FOREIGN KEY (period_id) REFERENCES financial_accounting_periods(period_id),
FOREIGN KEY (account_id) REFERENCES financial_chart_of_accounts(account_id)
) COMMENT='财务预算表';
-- 3. 科目层级管理增强
ALTER TABLE financial_chart_of_accounts
ADD COLUMN path VARCHAR(255) COMMENT '层级路径(如0/1001/)',
ADD COLUMN level INT COMMENT '层级深度';
-- 4. 数据血缘追踪表
CREATE TABLE financial_data_trace (
trace_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '追踪记录ID',
source_table VARCHAR(50) NOT NULL COMMENT '源表名',
source_id BIGINT NOT NULL COMMENT '源记录ID',
target_table VARCHAR(50) NOT NULL COMMENT '目标表名',
target_id BIGINT NOT NULL COMMENT '目标记录ID',
operation_type ENUM('insert','update','delete') NOT NULL COMMENT '操作类型',
operator VARCHAR(50) COMMENT '操作人',
operation_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间'
) COMMENT='数据操作追踪表';
-- 5. 会计期间重叠检查触发器
DELIMITER //
CREATE TRIGGER trg_check_period_overlap
BEFORE INSERT ON financial_accounting_periods
FOR EACH ROW
BEGIN
IF EXISTS (
SELECT 1 FROM financial_accounting_periods
WHERE company_id = NEW.company_id
AND period_id != NEW.period_id
AND (
(NEW.start_date BETWEEN start_date AND end_date)
OR (NEW.end_date BETWEEN start_date AND end_date)
OR (start_date BETWEEN NEW.start_date AND NEW.end_date)
)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '会计期间时间重叠';
END IF;
END//
DELIMITER ;
-- 6. 初始化会计期间状态
UPDATE financial_accounting_periods
SET status = CASE
WHEN end_date >= CURDATE() THEN 'active'
WHEN end_date < CURDATE() AND start_date > CURDATE() THEN 'locked'
ELSE 'closed'
END;
-- 7. 创建科目层级维护存储过程
DELIMITER //
CREATE PROCEDURE sp_refresh_account_hierarchy()
BEGIN
UPDATE financial_chart_of_accounts c
LEFT JOIN (
SELECT
a.account_id,
CONCAT(
IFNULL((SELECT CONCAT(path, parent_id, '/') FROM financial_chart_of_accounts
WHERE account_id = a.parent_id), '0/'),
a.account_id, '/'
) AS new_path,
(LENGTH(IFNULL((SELECT path FROM financial_chart_of_accounts
WHERE account_id = a.parent_id), '0/')) - LENGTH(REPLACE(IFNULL(
(SELECT path FROM financial_chart_of_accounts
WHERE account_id = a.parent_id), '0/'), '/', ''))) + 1
AS new_level
FROM financial_chart_of_accounts a
) AS sub ON c.account_id = sub.account_id
SET
c.path = sub.new_path,
c.level = sub.new_level
WHERE c.is_active = TRUE;
END//
DELIMITER ;

185
niucloud/app/service/school_approval/SchoolApprovalConfigService.php

@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace app\service\school_approval;
use app\model\school_approval\SchoolApprovalConfig;
use app\model\school_approval\SchoolApprovalConfigNode;
use think\Exception;
use think\facade\Db;
/**
* 审批流配置服务
* Class SchoolApprovalConfigService
* @package app\service\school_approval
*/
class SchoolApprovalConfigService
{
/**
* 获取审批流配置列表
* @param array $where
* @param int $page
* @param int $limit
* @return array
*/
public function getList(array $where = [], int $page = 1, int $limit = 10): array
{
$field = 'id, config_name, description, status, creator_id, created_at, updated_at';
$order = 'id desc';
$list = (new SchoolApprovalConfig())
->where($where)
->field($field)
->order($order)
->page($page, $limit)
->select()
->toArray();
$count = (new SchoolApprovalConfig())->where($where)->count();
return [
'list' => $list,
'count' => $count
];
}
/**
* 获取审批流配置详情
* @param int $id
* @return array
*/
public function getInfo(int $id): array
{
$info = (new SchoolApprovalConfig())->with(['nodes'])->where(['id' => $id])->find();
if (empty($info)) {
return [];
}
return $info->toArray();
}
/**
* 添加审批流配置
* @param array $data
* @return int
* @throws \Exception
*/
public function add(array $data): int
{
Db::startTrans();
try {
$config = [
'config_name' => $data['config_name'],
'description' => $data['description'] ?? '',
'status' => $data['status'] ?? 1,
'creator_id' => $data['creator_id']
];
$config_id = (new SchoolApprovalConfig())->insertGetId($config);
// 添加节点
if (!empty($data['nodes'])) {
$nodes = [];
foreach ($data['nodes'] as $sequence => $node) {
$nodes[] = [
'config_id' => $config_id,
'node_name' => $node['node_name'],
'approver_type' => $node['approver_type'],
'approver_ids' => is_array($node['approver_ids']) ? implode(',', $node['approver_ids']) : $node['approver_ids'],
'sign_type' => $node['sign_type'] ?? SchoolApprovalConfigNode::SIGN_TYPE_OR,
'sequence' => $sequence + 1
];
}
(new SchoolApprovalConfigNode())->insertAll($nodes);
}
Db::commit();
return $config_id;
} catch (\Exception $e) {
Db::rollback();
throw new Exception($e->getMessage());
}
}
/**
* 编辑审批流配置
* @param array $data
* @return bool
* @throws \Exception
*/
public function edit(array $data): bool
{
Db::startTrans();
try {
$config = [
'config_name' => $data['config_name'],
'description' => $data['description'] ?? '',
'status' => $data['status'] ?? 1
];
(new SchoolApprovalConfig())->where(['id' => $data['id']])->update($config);
// 先删除原有节点
(new SchoolApprovalConfigNode())->where(['config_id' => $data['id']])->delete();
// 添加新节点
if (!empty($data['nodes'])) {
$nodes = [];
foreach ($data['nodes'] as $sequence => $node) {
$nodes[] = [
'config_id' => $data['id'],
'node_name' => $node['node_name'],
'approver_type' => $node['approver_type'],
'approver_ids' => is_array($node['approver_ids']) ? implode(',', $node['approver_ids']) : $node['approver_ids'],
'sign_type' => $node['sign_type'] ?? SchoolApprovalConfigNode::SIGN_TYPE_OR,
'sequence' => $sequence + 1
];
}
(new SchoolApprovalConfigNode())->insertAll($nodes);
}
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
throw new Exception($e->getMessage());
}
}
/**
* 删除审批流配置
* @param int $id
* @return bool
* @throws \Exception
*/
public function delete(int $id): bool
{
Db::startTrans();
try {
// 删除配置
(new SchoolApprovalConfig())->where(['id' => $id])->delete();
// 删除节点
(new SchoolApprovalConfigNode())->where(['config_id' => $id])->delete();
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
throw new Exception($e->getMessage());
}
}
/**
* 修改状态
* @param int $id
* @param int $status
* @return bool
*/
public function changeStatus(int $id, int $status): bool
{
return (new SchoolApprovalConfig())->where(['id' => $id])->update(['status' => $status]) !== false;
}
}

289
niucloud/app/service/school_approval/SchoolApprovalProcessService.php

@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace app\service\school_approval;
use app\model\school_approval\SchoolApprovalConfig;
use app\model\school_approval\SchoolApprovalConfigNode;
use app\model\school_approval\SchoolApprovalParticipants;
use app\model\school_approval\SchoolApprovalProcess;
use think\Exception;
use think\facade\Db;
/**
* 审批流程服务
* Class SchoolApprovalProcessService
* @package app\service\school_approval
*/
class SchoolApprovalProcessService
{
/**
* 获取审批流程列表
* @param array $where
* @param int $page
* @param int $limit
* @return array
*/
public function getList(array $where = [], int $page = 1, int $limit = 10): array
{
$field = 'id, process_name, applicant_id, application_time, current_approver_id, approval_status, approval_time, remarks, created_at, updated_at';
$order = 'id desc';
$list = (new SchoolApprovalProcess())
->where($where)
->field($field)
->order($order)
->page($page, $limit)
->select()
->toArray();
$count = (new SchoolApprovalProcess())->where($where)->count();
return [
'list' => $list,
'count' => $count
];
}
/**
* 获取审批流程详情
* @param int $id
* @return array
*/
public function getInfo(int $id): array
{
$info = (new SchoolApprovalProcess())->with(['participants'])->where(['id' => $id])->find();
if (empty($info)) {
return [];
}
return $info->toArray();
}
/**
* 创建审批流程
* @param array $data
* @param int $config_id 审批配置ID
* @return int
* @throws \Exception
*/
public function create(array $data, int $config_id): int
{
Db::startTrans();
try {
// 获取审批配置详情
$config_info = (new SchoolApprovalConfigService())->getInfo($config_id);
if (empty($config_info)) {
throw new Exception('审批配置不存在');
}
// 创建审批流程
$process = [
'process_name' => $data['process_name'],
'applicant_id' => $data['applicant_id'],
'application_time' => time(),
'current_approver_id' => 0, // 初始时为0,后面会更新
'approval_status' => SchoolApprovalProcess::STATUS_PENDING,
'remarks' => $data['remarks'] ?? ''
];
$process_id = (new SchoolApprovalProcess())->insertGetId($process);
// 创建审批参与人
$participants = [];
foreach ($config_info['nodes'] as $sequence => $node) {
$approver_ids = explode(',', $node['approver_ids']);
foreach ($approver_ids as $approver_id) {
$participants[] = [
'process_id' => $process_id,
'participant_id' => $approver_id,
'sequence' => $node['sequence'],
'status' => SchoolApprovalParticipants::STATUS_PENDING,
'sign_type' => $node['sign_type']
];
}
}
if (!empty($participants)) {
(new SchoolApprovalParticipants())->insertAll($participants);
// 更新当前审批人为第一个审批人
$first_participant = (new SchoolApprovalParticipants())
->where(['process_id' => $process_id])
->order('sequence', 'asc')
->find();
if (!empty($first_participant)) {
(new SchoolApprovalProcess())->where(['id' => $process_id])
->update(['current_approver_id' => $first_participant['participant_id']]);
}
}
Db::commit();
return $process_id;
} catch (\Exception $e) {
Db::rollback();
throw new Exception($e->getMessage());
}
}
/**
* 审批
* @param int $process_id 流程ID
* @param int $approver_id 审批人ID
* @param string $status 审批状态
* @param string $remarks 备注
* @return bool
* @throws \Exception
*/
public function approve(int $process_id, int $approver_id, string $status, string $remarks = ''): bool
{
Db::startTrans();
try {
// 获取审批流程
$process_info = (new SchoolApprovalProcess())->where(['id' => $process_id])->find();
if (empty($process_info)) {
throw new Exception('审批流程不存在');
}
// 检查是否当前审批人
if ($process_info['current_approver_id'] != $approver_id) {
throw new Exception('您不是当前审批人');
}
// 检查流程状态
if ($process_info['approval_status'] != SchoolApprovalProcess::STATUS_PENDING) {
throw new Exception('该审批流程已完成');
}
// 获取当前审批节点
$current_participant = (new SchoolApprovalParticipants())
->where([
'process_id' => $process_id,
'participant_id' => $approver_id,
'status' => SchoolApprovalParticipants::STATUS_PENDING
])
->find();
if (empty($current_participant)) {
throw new Exception('审批节点信息错误');
}
// 更新当前审批人状态
(new SchoolApprovalParticipants())->where(['id' => $current_participant['id']])
->update([
'status' => $status,
'remarks' => $remarks
]);
// 如果拒绝,直接更新整个流程状态为拒绝
if ($status == SchoolApprovalParticipants::STATUS_REJECTED) {
(new SchoolApprovalProcess())->where(['id' => $process_id])
->update([
'approval_status' => SchoolApprovalProcess::STATUS_REJECTED,
'approval_time' => time(),
'remarks' => $remarks
]);
Db::commit();
return true;
}
// 检查当前节点是否需要会签
$same_sequence_participants = (new SchoolApprovalParticipants())
->where([
'process_id' => $process_id,
'sequence' => $current_participant['sequence'],
'status' => SchoolApprovalParticipants::STATUS_PENDING
])
->select();
// 如果是会签且还有其他人未审批,则等待
if ($current_participant['sign_type'] == SchoolApprovalParticipants::SIGN_TYPE_AND && !$same_sequence_participants->isEmpty()) {
// 不做任何处理,等待其他人审批
Db::commit();
return true;
}
// 获取下一个审批节点
$next_participant = (new SchoolApprovalParticipants())
->where([
'process_id' => $process_id,
'status' => SchoolApprovalParticipants::STATUS_PENDING
])
->order('sequence', 'asc')
->find();
if (empty($next_participant)) {
// 没有下一个审批人,流程结束,标记为已通过
(new SchoolApprovalProcess())->where(['id' => $process_id])
->update([
'approval_status' => SchoolApprovalProcess::STATUS_APPROVED,
'approval_time' => time()
]);
} else {
// 更新当前审批人为下一个审批人
(new SchoolApprovalProcess())->where(['id' => $process_id])
->update(['current_approver_id' => $next_participant['participant_id']]);
}
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
throw new Exception($e->getMessage());
}
}
/**
* 撤销审批流程
* @param int $process_id 流程ID
* @param int $applicant_id 申请人ID
* @return bool
* @throws \Exception
*/
public function cancel(int $process_id, int $applicant_id): bool
{
Db::startTrans();
try {
// 获取审批流程
$process_info = (new SchoolApprovalProcess())->where(['id' => $process_id])->find();
if (empty($process_info)) {
throw new Exception('审批流程不存在');
}
// 检查是否申请人
if ($process_info['applicant_id'] != $applicant_id) {
throw new Exception('您不是该流程的申请人');
}
// 检查流程状态
if ($process_info['approval_status'] != SchoolApprovalProcess::STATUS_PENDING) {
throw new Exception('该审批流程已完成,无法撤销');
}
// 更新流程状态为已拒绝(撤销)
(new SchoolApprovalProcess())->where(['id' => $process_id])
->update([
'approval_status' => SchoolApprovalProcess::STATUS_REJECTED,
'approval_time' => time(),
'remarks' => '申请人撤销'
]);
// 更新所有待审批节点为已拒绝
(new SchoolApprovalParticipants())->where([
'process_id' => $process_id,
'status' => SchoolApprovalParticipants::STATUS_PENDING
])->update([
'status' => SchoolApprovalParticipants::STATUS_REJECTED,
'remarks' => '申请人撤销'
]);
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
throw new Exception($e->getMessage());
}
}
}
Loading…
Cancel
Save