merge: feat/approval-phase1 合并入 t26 导出分支

This commit is contained in:
2026-10-30 21:23:37 +08:00
6 changed files with 954 additions and 1 deletions
+16 -1
View File
@@ -4,7 +4,8 @@ import type {
ApprovalExportFile,
ApprovalInstance,
ApprovalItem,
ApprovalQueryParam
ApprovalQueryParam,
ApprovalTodoParam
} from './model';
import { THINK_API_URL } from '@/config/setting';
@@ -22,6 +23,20 @@ export async function pageInstances(params: ApprovalQueryParam) {
return Promise.reject(new Error(res.data.message));
}
/**
* 我的待办分页
*/
export async function pageApprovalTodo(params: ApprovalTodoParam) {
const res = await request.get<ApiResult<PageResult<ApprovalItem>>>(
THINK_API_URL + '/approval/todo/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 单据详情(只读)
*/
+35
View File
@@ -182,3 +182,38 @@ export interface ApprovalExportFile {
contentType?: string;
url?: string;
}
/**
* 待办/已办查询条件
*/
export interface ApprovalTodoParam extends PageParam {
// 单据状态
status?: string;
// 单据类型
docType?: string;
// 关键词:单号、名称或标题、实施单位
keywords?: string;
}
/**
* 待办改派参数
*/
export interface ApprovalAssignParam {
// 改派对象用户id
targetUserId: number;
// 改派原因
reason: string;
}
/**
* 改派结果
*/
export interface ApprovalAssignResult {
instanceId?: number;
taskId?: number;
originalAssigneeId?: number;
originalAssigneeName?: string;
assigneeId?: number;
assigneeName?: string;
reason?: string;
}
+78
View File
@@ -0,0 +1,78 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { THINK_API_URL } from '@/config/setting';
import type {
ApprovalAssignParam,
ApprovalAssignResult
} from '../instance/model';
/**
* 通过任务
*/
export async function approveApprovalTask(id: number, opinion?: string) {
const res = await request.post<ApiResult<unknown>>(
THINK_API_URL + '/approval/task/' + id + '/approve',
{ opinion }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 驳回任务(意见必填)
*/
export async function rejectApprovalTask(id: number, opinion: string) {
const res = await request.post<ApiResult<unknown>>(
THINK_API_URL + '/approval/task/' + id + '/reject',
{ opinion }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 存档确认
*/
export async function confirmApprovalTask(id: number) {
const res = await request.post<ApiResult<unknown>>(
THINK_API_URL + '/approval/task/' + id + '/confirm'
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 归档
*/
export async function archiveApprovalTask(id: number) {
const res = await request.post<ApiResult<unknown>>(
THINK_API_URL + '/approval/task/' + id + '/archive'
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 改派待办
*/
export async function assignApprovalTask(
id: number,
data: ApprovalAssignParam
) {
const res = await request.post<ApiResult<ApprovalAssignResult>>(
THINK_API_URL + '/approval/task/' + id + '/assign',
data
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
@@ -0,0 +1,561 @@
<!-- 审批待办详情抽屉 -->
<template>
<a-drawer
:width="860"
:visible="visible"
title="待办详情"
:body-style="{ padding: '16px' }"
:footer="null"
@update:visible="updateVisible"
>
<a-spin :spinning="loading">
<template v-if="detail">
<!-- 动作按钮:按详情权限与按钮权限显隐 -->
<div class="approval-detail-actions">
<a-space>
<a-button
v-if="detail.permissions?.canApprove"
v-permission="'approval:instance:approve'"
type="primary"
@click="openOpinion('approve')"
>
通过
</a-button>
<a-button
v-if="detail.permissions?.canReject"
v-permission="'approval:instance:approve'"
danger
@click="openOpinion('reject')"
>
驳回
</a-button>
<a-button
v-if="detail.permissions?.canConfirm"
v-permission="'approval:instance:approve'"
type="primary"
@click="confirmStore"
>
存档
</a-button>
<a-button
v-if="detail.permissions?.canArchive"
v-permission="'approval:instance:archive'"
type="primary"
@click="archive"
>
归档
</a-button>
<a-button
v-if="detail.permissions?.canAssign"
v-permission="'approval:instance:assign'"
@click="openAssign"
>
改派
</a-button>
</a-space>
<a-tag v-if="detail.statusName" :color="statusColor(detail.status)">
{{ detail.statusName }}
</a-tag>
</div>
<a-descriptions bordered size="small" :column="2" title="基本信息">
<a-descriptions-item label="单号">
{{ detail.bizNo }}
</a-descriptions-item>
<a-descriptions-item label="单据类型">
{{ detail.docTypeName }}
</a-descriptions-item>
<a-descriptions-item label="当前节点">
{{ detail.currentNodeName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="发起人">
{{ detail.reporterName }}
</a-descriptions-item>
<a-descriptions-item label="报送部门">
{{ detail.reportOrgName }}
</a-descriptions-item>
<a-descriptions-item
v-if="detail.docType === 'coordination'"
label="受理部门"
>
{{ detail.acceptOrgName }}
</a-descriptions-item>
<a-descriptions-item label="提交时间">
{{ formatApprovalTime(detail.submitTime) }}
</a-descriptions-item>
<a-descriptions-item label="审批完成时间">
{{ formatApprovalTime(detail.approveTime) }}
</a-descriptions-item>
<a-descriptions-item label="归档时间">
{{ formatApprovalTime(detail.archiveTime) }}
</a-descriptions-item>
<a-descriptions-item label="归档人">
{{ detail.archivistName }}
</a-descriptions-item>
</a-descriptions>
<a-descriptions
v-if="detail.docType === 'completion'"
bordered
size="small"
:column="2"
title="单据字段"
>
<a-descriptions-item label="名称" :span="2">
{{ detail.name }}
</a-descriptions-item>
<a-descriptions-item label="实施单位">
{{ detail.implementOrg }}
</a-descriptions-item>
<a-descriptions-item label="费用(元)">
{{ formatFee(detail.fee) }}
</a-descriptions-item>
<a-descriptions-item label="服务内容" :span="2">
{{ detail.serviceContent }}
</a-descriptions-item>
</a-descriptions>
<a-descriptions
v-else-if="detail.docType === 'coordination'"
bordered
size="small"
:column="2"
title="单据字段"
>
<a-descriptions-item label="标题" :span="2">
{{ detail.title }}
</a-descriptions-item>
<a-descriptions-item label="协调内容" :span="2">
{{ detail.content }}
</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">
{{ detail.remark }}
</a-descriptions-item>
<a-descriptions-item label="派单对象">
{{ detail.dispatchTargetName || '-' }}
</a-descriptions-item>
</a-descriptions>
<a-descriptions bordered size="small" :column="2" title="附件">
<a-descriptions-item label="附件" :span="2">
<a-empty
v-if="!attachments.length"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无附件"
/>
<div
v-for="file in attachments"
:key="file.id"
class="approval-file"
>
<a @click="openFile(file)">{{ file.name }}</a>
<span class="ele-text-secondary">
{{ file.categoryName }} · {{ formatFileSize(file.length) }} ·
{{ formatApprovalTime(file.createTime) }}
</span>
</div>
</a-descriptions-item>
</a-descriptions>
<a-descriptions bordered size="small" :column="2" title="当前节点">
<a-descriptions-item label="节点">
{{ detail.currentNodeName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="存档确认">
<template v-if="confirms.length">
<a-tag
v-for="item in confirms"
:key="item.userId"
:color="item.status === 'DONE' ? 'green' : 'blue'"
>
{{ item.userName }}·{{ item.statusName }}
</a-tag>
</template>
<span v-else>-</span>
</a-descriptions-item>
</a-descriptions>
<div class="approval-section-title">处理记录</div>
<a-empty
v-if="!records.length"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无处理记录"
/>
<a-timeline v-else>
<a-timeline-item
v-for="item in records"
:key="item.id"
:color="item.action === 'REJECT' ? 'red' : 'blue'"
>
<div>
<span class="approval-timeline-action">{{
item.actionName
}}</span>
<span class="ele-text-secondary">
{{ item.operatorName }} ·
{{ formatApprovalTime(item.createTime) }}
</span>
</div>
<div v-if="item.nodeName" class="ele-text-secondary">
{{ item.nodeName }}
</div>
<div v-if="item.opinion">{{ item.opinion }}</div>
</a-timeline-item>
</a-timeline>
</template>
<a-empty v-else-if="!loading" description="暂无数据" />
</a-spin>
<!-- 通过/驳回意见 -->
<a-modal
v-model:visible="opinionVisible"
:title="opinionMode === 'approve' ? '通过' : '驳回'"
:confirm-loading="submitting"
:mask-closable="false"
@ok="submitOpinion"
>
<a-textarea
v-model:value="opinion"
:rows="4"
:maxlength="500"
show-count
:placeholder="
opinionMode === 'approve'
? '请输入意见(可选)'
: '请输入驳回意见(必填)'
"
/>
</a-modal>
<!-- 改派 -->
<a-modal
v-model:visible="assignVisible"
title="改派待办"
:confirm-loading="submitting"
:mask-closable="false"
@ok="submitAssign"
>
<a-form layout="vertical">
<a-form-item label="改派对象" required>
<a-select
v-model:value="assignForm.targetUserId"
show-search
allow-clear
:filter-option="false"
:options="userOptions"
:loading="userLoading"
placeholder="搜索姓名或账号"
@search="searchUsers"
/>
</a-form-item>
<a-form-item label="改派原因" required>
<a-textarea
v-model:value="assignForm.reason"
:rows="3"
:maxlength="500"
show-count
placeholder="请输入改派原因"
/>
</a-form-item>
</a-form>
</a-modal>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { Empty, Modal, message } from 'ant-design-vue';
import type {
ApprovalAttachment,
ApprovalInstance
} from '@/api/approval/instance/model';
import { getInstance } from '@/api/approval/instance';
import { searchApprovalUsers } from '@/api/approval/directory';
import {
approveApprovalTask,
archiveApprovalTask,
assignApprovalTask,
confirmApprovalTask,
rejectApprovalTask
} from '@/api/approval/task';
import {
approvalStatusColor,
formatApprovalTime,
formatFileSize
} from '../format';
const props = defineProps<{
// 抽屉是否打开
visible: boolean;
// 单据id
instanceId?: number | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
// 单据状态已变化,需要刷新列表
(e: 'done'): void;
}>();
// 详情数据
const detail = ref<ApprovalInstance | null>(null);
// 加载状态
const loading = ref(false);
// 提交状态
const submitting = ref(false);
const attachments = computed<ApprovalAttachment[]>(
() => detail.value?.attachments ?? []
);
const records = computed(() => detail.value?.records ?? []);
const confirms = computed(() => detail.value?.confirms ?? []);
const load = async () => {
if (!props.instanceId) {
detail.value = null;
return;
}
loading.value = true;
try {
detail.value = await getInstance(props.instanceId);
} catch (e: any) {
detail.value = null;
message.error(e.message);
} finally {
loading.value = false;
}
};
watch(
() => [props.visible, props.instanceId],
() => {
if (props.visible && props.instanceId) {
load();
}
},
{ immediate: true }
);
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 状态颜色
const statusColor = approvalStatusColor;
const formatFee = (fee?: number) => {
return fee === undefined || fee === null ? '-' : Number(fee).toFixed(2);
};
const openFile = (file: ApprovalAttachment) => {
if (file.url) {
window.open(file.url);
}
};
/* 通过 / 驳回 */
const opinionVisible = ref(false);
const opinionMode = ref<'approve' | 'reject'>('approve');
const opinion = ref('');
const openOpinion = (mode: 'approve' | 'reject') => {
opinionMode.value = mode;
opinion.value = '';
opinionVisible.value = true;
};
const submitOpinion = async () => {
const taskId = detail.value?.myTask?.id;
if (!taskId) {
message.error('待办任务不存在,请刷新');
return;
}
const text = opinion.value.trim();
if (opinionMode.value === 'reject' && !text) {
message.error('请填写驳回意见');
return;
}
submitting.value = true;
try {
if (opinionMode.value === 'approve') {
const msg = await approveApprovalTask(taskId, text || undefined);
if (msg && msg !== '审批成功') {
message.warning(msg);
} else {
message.success('审批成功');
}
} else {
await rejectApprovalTask(taskId, text);
message.success('驳回成功');
}
opinionVisible.value = false;
await load();
emit('done');
} catch (e: any) {
message.error(e.message);
} finally {
submitting.value = false;
}
};
/* 存档 / 归档 */
const confirmStore = () => {
const taskId = detail.value?.myTask?.id;
if (!taskId) {
message.error('待办任务不存在,请刷新');
return;
}
Modal.confirm({
title: '提示',
content: '确认完成存档?',
onOk: async () => {
try {
await confirmApprovalTask(taskId);
message.success('存档成功');
await load();
emit('done');
} catch (e: any) {
message.error(e.message);
}
}
});
};
const archive = () => {
const taskId = detail.value?.myTask?.id;
if (!taskId) {
message.error('待办任务不存在,请刷新');
return;
}
Modal.confirm({
title: '提示',
content: '确认归档该单据?归档后不可再修改。',
onOk: async () => {
try {
await archiveApprovalTask(taskId);
message.success('归档成功');
await load();
emit('done');
} catch (e: any) {
message.error(e.message);
}
}
});
};
/* 改派 */
const assignVisible = ref(false);
const userLoading = ref(false);
const userOptions = ref<{ label: string; value: number }[]>([]);
const assignForm = reactive<{ targetUserId?: number; reason: string }>({
targetUserId: undefined,
reason: ''
});
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const doSearchUsers = async (keywords?: string) => {
userLoading.value = true;
try {
const users = await searchApprovalUsers({
keywords: keywords?.trim() || undefined
});
userOptions.value = users.map((user) => ({
value: user.userId as number,
label:
(user.realName || user.nickname || user.username || '') +
(user.organizationName ? '(' + user.organizationName + ')' : '')
}));
} catch (e: any) {
message.error(e.message);
} finally {
userLoading.value = false;
}
};
const searchUsers = (keywords?: string) => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => doSearchUsers(keywords), 300);
};
const openAssign = () => {
assignForm.targetUserId = undefined;
assignForm.reason = '';
assignVisible.value = true;
doSearchUsers();
};
const submitAssign = async () => {
const taskId = detail.value?.myTask?.id;
if (!taskId) {
message.error('待办任务不存在,请刷新');
return;
}
if (!assignForm.targetUserId) {
message.error('请选择改派对象');
return;
}
const reason = assignForm.reason.trim();
if (!reason) {
message.error('请填写改派原因');
return;
}
submitting.value = true;
try {
await assignApprovalTask(taskId, {
targetUserId: assignForm.targetUserId,
reason
});
message.success('改派成功');
assignVisible.value = false;
emit('done');
// 改派后待办不再属于当前用户,关闭抽屉
updateVisible(false);
} catch (e: any) {
message.error(e.message);
} finally {
submitting.value = false;
}
};
</script>
<script lang="ts">
export default {
name: 'ApprovalTodoDetail'
};
</script>
<style lang="less" scoped>
.approval-detail-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.approval-section-title {
margin: 24px 0 16px;
font-size: 16px;
font-weight: 500;
}
.approval-file {
display: flex;
flex-direction: column;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
.approval-timeline-action {
margin-right: 8px;
font-weight: 500;
}
:deep(.ant-descriptions) {
margin-bottom: 16px;
}
</style>
+46
View File
@@ -0,0 +1,46 @@
import { toDateString } from 'ele-admin-pro';
/**
* 审批时间统一展示:兼容 ISO 字符串与时间戳
*/
export function formatApprovalTime(value?: string | number | null): string {
if (value === undefined || value === null || value === '') {
return '';
}
if (typeof value === 'number') {
return toDateString(value, 'yyyy-MM-dd HH:mm:ss');
}
return String(value).replace('T', ' ').slice(0, 19);
}
/**
* 附件大小展示
*/
export function formatFileSize(length?: number): string {
if (!length || length <= 0) {
return '0 KB';
}
if (length < 1024 * 1024) {
return (length / 1024).toFixed(1) + ' KB';
}
return (length / 1024 / 1024).toFixed(1) + ' MB';
}
/**
* 单据状态标签颜色
*/
export function approvalStatusColor(status?: string): string {
if (status === 'PENDING' || status === 'DRAFT') {
return 'blue';
}
if (status === 'REJECTED') {
return 'red';
}
if (status === 'WITHDRAWN') {
return 'orange';
}
if (status === 'APPROVED') {
return 'green';
}
return 'default';
}
+218
View File
@@ -0,0 +1,218 @@
<!-- 审批管理 - 待办审批 -->
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
tool-class="ele-toolbar-form"
class="approval-todo-table"
:scroll="{ x: 1200 }"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="where.status"
allow-clear
placeholder="单据状态"
style="width: 160px"
:options="statusOptions"
@change="reload"
/>
<a-input-search
v-model:value="where.keywords"
allow-clear
placeholder="请输入单号/标题/实施单位"
style="width: 320px"
@pressEnter="reload"
@search="reload"
/>
<a-button @click="resetSearch">重置</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="approvalStatusColor(record.status)">
{{ record.statusName }}
</a-tag>
</template>
<template v-if="column.key === 'submitTime'">
{{ formatApprovalTime(record.submitTime) }}
</template>
<template v-if="column.key === 'action'">
<a @click="openDetail(record)">详情</a>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 详情抽屉 -->
<detail-drawer
v-model:visible="showDetail"
:instance-id="currentId"
@done="reload"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import useSearch from '@/utils/use-search';
import type {
ApprovalItem,
ApprovalTodoParam
} from '@/api/approval/instance/model';
import { pageApprovalTodo } from '@/api/approval/instance';
import { approvalStatusColor, formatApprovalTime } from './format';
import DetailDrawer from './components/detail-drawer.vue';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 详情抽屉
const showDetail = ref(false);
// 当前单据id
const currentId = ref<number | null>(null);
// 搜索条件
const { where, resetFields } = useSearch<ApprovalTodoParam>({
status: undefined,
keywords: undefined
});
// 状态选项
const statusOptions = [
{ label: '审批中', value: 'PENDING' },
{ label: '已驳回', value: 'REJECTED' },
{ label: '已撤回', value: 'WITHDRAWN' },
{ label: '审批完成', value: 'APPROVED' },
{ label: '已归档', value: 'ARCHIVED' }
];
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: '单号',
dataIndex: 'bizNo',
key: 'bizNo',
width: 150,
align: 'center'
},
{
title: '单据类型',
dataIndex: 'docTypeName',
key: 'docTypeName',
width: 140,
align: 'center'
},
{
title: '标题或名称',
dataIndex: 'title',
key: 'title',
minWidth: 200,
ellipsis: true
},
{
title: '发起人',
dataIndex: 'reporterName',
key: 'reporterName',
width: 120,
align: 'center'
},
{
title: '报送部门',
dataIndex: 'reportOrgName',
key: 'reportOrgName',
width: 160,
ellipsis: true
},
{
title: '状态',
dataIndex: 'statusName',
key: 'status',
width: 110,
align: 'center'
},
{
title: '提交时间',
dataIndex: 'submitTime',
key: 'submitTime',
width: 170,
align: 'center'
},
{
title: '当前节点',
dataIndex: 'currentNodeName',
key: 'currentNodeName',
width: 140,
align: 'center'
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
// 表格数据源
const datasource: DatasourceFunction = ({ page, limit }) => {
return pageApprovalTodo({
status: where.status,
keywords: where.keywords?.trim() || undefined,
page,
limit
});
};
// 重新加载表格
const reload = () => {
tableRef.value?.reload({ page: 1 });
};
// 重置搜索
const resetSearch = () => {
resetFields();
reload();
};
// 打开详情
const openDetail = (row: ApprovalItem) => {
currentId.value = row.id ?? null;
showDetail.value = true;
};
// 行双击打开详情
const customRow = (record: ApprovalItem) => {
return {
onDblclick: () => {
openDetail(record);
}
};
};
</script>
<script lang="ts">
export default {
name: 'ApprovalTodo'
};
</script>
<style lang="less" scoped>
.approval-todo-table {
:deep(.ant-table-cell) {
vertical-align: middle;
}
}
</style>