更新采购申请

This commit is contained in:
吴红兵
2026-03-01 16:21:26 +08:00
parent efc5dda78a
commit 22fc35b87e
93 changed files with 45 additions and 45 deletions

View File

@@ -0,0 +1,162 @@
<template>
<el-dialog
:title="dataForm.id ? '编辑' : '新增'"
v-model="visible"
width="600px"
:close-on-click-modal="false"
draggable>
<el-form
ref="formRef"
:model="dataForm"
:rules="dataRules"
label-width="100px"
v-loading="loading">
<el-form-item label="代理名称" prop="agentName">
<el-input
v-model="dataForm.agentName"
placeholder="请输入代理名称"
clearable />
</el-form-item>
<el-form-item label="联系人" prop="contactPerson">
<el-input
v-model="dataForm.contactPerson"
placeholder="请输入联系人"
clearable />
</el-form-item>
<el-form-item label="联系电话" prop="contactPhone">
<el-input
v-model="dataForm.contactPhone"
placeholder="请输入联系电话"
clearable />
</el-form-item>
<el-form-item label="登录用户名" prop="username">
<el-input
v-model="dataForm.username"
placeholder="请输入登录用户名(不填则自动生成)"
clearable
:disabled="!!dataForm.id" />
<div class="form-tip" v-if="!dataForm.id">新增时自动创建系统用户默认密码Aa123456角色招标代理</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="dataForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
clearable />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false"> </el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading"> </el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="PurchaseAgentForm">
import { reactive, ref, nextTick } from 'vue'
import { getObj, addObj, editObj } from '/@/api/purchase/purchaseagent';
import { useMessage } from '/@/hooks/message';
// 定义子组件向父组件传值/事件
const emit = defineEmits(['refresh']);
// 定义变量内容
const formRef = ref();
const dataForm = reactive({
id: '',
agentName: '',
contactPerson: '',
contactPhone: '',
username: '',
remark: '',
});
const visible = ref(false);
const loading = ref(false);
const dataRules = ref({
agentName: [
{ required: true, message: '请输入代理名称', trigger: 'blur' }
],
});
// 打开弹窗
const openDialog = async (type: string, rowData?: any) => {
visible.value = true;
dataForm.id = '';
dataForm.agentName = '';
dataForm.contactPerson = '';
dataForm.contactPhone = '';
dataForm.username = '';
dataForm.remark = '';
nextTick(() => {
formRef.value?.resetFields();
if (type === 'edit' && rowData?.id) {
// 编辑时,先获取详情数据
loading.value = true;
getObj(rowData.id).then((res: any) => {
if (res.data) {
Object.assign(dataForm, {
id: res.data.id || '',
agentName: res.data.agentName || '',
contactPerson: res.data.contactPerson || '',
contactPhone: res.data.contactPhone || '',
username: res.data.username || '',
remark: res.data.remark || '',
});
}
loading.value = false;
}).catch((err: any) => {
useMessage().error(err.msg || '获取详情失败');
loading.value = false;
});
}
});
};
// 提交
const onSubmit = async () => {
// 立即设置 loading防止重复点击
if (loading.value) return;
loading.value = true;
try {
const valid = await formRef.value.validate().catch(() => {});
if (!valid) {
loading.value = false;
return false;
}
if (dataForm.id) {
await editObj(dataForm);
useMessage().success('编辑成功');
} else {
await addObj(dataForm);
useMessage().success('新增成功');
}
visible.value = false;
emit('refresh');
} catch (err: any) {
useMessage().error(err.msg || (dataForm.id ? '编辑失败' : '新增失败'));
} finally {
loading.value = false;
}
};
// 暴露变量
defineExpose({
openDialog,
});
</script>
<style scoped>
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,360 @@
<template>
<div class="modern-page-container">
<div class="page-wrapper">
<!-- 搜索表单卡片 -->
<el-card v-show="showSearch" class="search-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="title-icon"><Search /></el-icon>
筛选条件
</span>
</div>
</template>
<el-form :model="state.queryForm" ref="searchFormRef" :inline="true" @keyup.enter="getDataList" class="search-form">
<el-form-item label="代理名称" prop="agentName">
<el-input
v-model="state.queryForm.agentName"
placeholder="请输入代理名称"
clearable
style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="getDataList">查询</el-button>
<el-button icon="Refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 内容卡片 -->
<el-card class="content-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="title-icon"><Document /></el-icon>
招标代理管理
</span>
<div class="header-actions">
<el-button
icon="Files"
link
type="primary"
@click="openSummaryDialog"
>
代理汇总
</el-button>
<el-button
icon="FolderAdd"
type="primary"
@click="formDialogRef.openDialog('add')"
v-auth="'purchase_purchasingagent_add'">
新增
</el-button>
<right-toolbar v-model:showSearch="showSearch" class="ml10" @queryTable="getDataList" />
</div>
</div>
</template>
<!-- 表格 -->
<el-table
ref="tableRef"
:data="state.dataList"
v-loading="state.loading"
stripe
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
class="modern-table">
<el-table-column type="index" label="序号" width="70" align="center">
<template #header>
<el-icon><List /></el-icon>
</template>
</el-table-column>
<el-table-column prop="agentName" label="代理名称" min-width="200" show-overflow-tooltip>
<template #header>
<el-icon><Document /></el-icon>
<span style="margin-left: 4px">代理名称</span>
</template>
</el-table-column>
<el-table-column prop="contactPerson" label="联系人" width="120" show-overflow-tooltip>
<template #header>
<el-icon><User /></el-icon>
<span style="margin-left: 4px">联系人</span>
</template>
</el-table-column>
<el-table-column prop="contactPhone" label="联系电话" width="140" show-overflow-tooltip>
<template #header>
<el-icon><Phone /></el-icon>
<span style="margin-left: 4px">联系电话</span>
</template>
</el-table-column>
<el-table-column prop="username" label="登录用户名" width="140" show-overflow-tooltip>
<template #header>
<el-icon><UserFilled /></el-icon>
<span style="margin-left: 4px">登录用户名</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip>
<template #header>
<el-icon><EditPen /></el-icon>
<span style="margin-left: 4px">备注</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" show-overflow-tooltip>
<template #header>
<el-icon><Clock /></el-icon>
<span style="margin-left: 4px">创建时间</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" width="150">
<template #default="scope">
<el-button
icon="Edit"
link
type="primary"
v-auth="'purchase_purchasingagent_edit'"
@click="formDialogRef.openDialog('edit', scope.row)">
编辑
</el-button>
<el-button
icon="Delete"
link
type="danger"
v-auth="'purchase_purchasingagent_del'"
@click="handleDelete(scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination
v-show="state.total > 0"
:total="state.total"
v-model:page="state.page"
v-model:limit="state.limit"
@pagination="getDataList"
/>
</el-card>
</div>
<!-- 编辑新增表单对话框 -->
<FormDialog ref="formDialogRef" @refresh="getDataList" />
<!-- 代理汇总弹窗 -->
<el-dialog
v-model="summaryDialogVisible"
title="代理数据汇总"
width="85%"
destroy-on-close
class="agent-summary-dialog"
>
<el-form :model="summaryQuery" inline class="summary-query-form">
<el-form-item label="需求部门">
<el-select
v-model="summaryQuery.deptCode"
placeholder="请选择"
clearable
filterable
style="width: 200px"
>
<el-option v-for="d in deptOptions" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
</el-form-item>
<el-form-item label="计划采购开始时间">
<el-date-picker
v-model="summaryQuery.planStartDate"
type="date"
placeholder="请选择"
value-format="YYYY-MM-DD"
style="width: 180px"
clearable
/>
</el-form-item>
<el-form-item label="计划采购结束时间">
<el-date-picker
v-model="summaryQuery.planEndDate"
type="date"
placeholder="请选择"
value-format="YYYY-MM-DD"
style="width: 180px"
clearable
/>
</el-form-item>
<el-form-item label="是否履约评价">
<el-select v-model="summaryQuery.hasAcceptEvaluation" placeholder="请选择" clearable style="width: 140px">
<el-option label="已履约评价" value="1" />
<el-option label="未履约评价" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="querySummary">查询</el-button>
<el-button @click="clearSummaryQuery">清空</el-button>
</el-form-item>
</el-form>
<el-table
:data="summaryList"
v-loading="summaryLoading"
border
stripe
max-height="400"
class="summary-table"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="agentName" label="代理机构" min-width="200" show-overflow-tooltip />
<el-table-column prop="projectCount" label="代理项目" width="100" align="center" />
<el-table-column prop="budgetAmount" label="预算金额" width="140" align="right">
<template #default="{ row }">
{{ formatMoney(row.budgetAmount) }}
</template>
</el-table-column>
<el-table-column prop="contractAmount" label="合同(成交)金额" width="140" align="right">
<template #default="{ row }">
{{ formatMoney(row.contractAmount) }}
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="PurchaseAgent">
import { ref, reactive, defineAsyncComponent } from 'vue'
import { BasicTableProps, useTable } from "/@/hooks/table";
import { getPage, delObj, getAgentSummary } from "/@/api/purchase/purchaseagent";
import { deptTree } from '/@/api/admin/dept';
import { useMessage, useMessageBox } from "/@/hooks/message";
import { List, Document, EditPen, Clock, Search, User, Phone, UserFilled } from '@element-plus/icons-vue'
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
/** 代理汇总弹窗 */
const summaryDialogVisible = ref(false);
const summaryLoading = ref(false);
const summaryList = ref<any[]>([]);
const summaryQuery = reactive({
deptCode: '',
planStartDate: '',
planEndDate: '',
hasAcceptEvaluation: '',
});
const deptOptions = ref<{ label: string; value: string }[]>([]);
const flattenDept = (nodes: any[], list: { label: string; value: string }[]) => {
if (!nodes || !Array.isArray(nodes)) return;
nodes.forEach((n: any) => {
const code = n.deptId ?? n.id ?? n.deptCode;
const name = n.name ?? n.deptName ?? '';
if (code != null && String(code)) list.push({ label: name || String(code), value: String(code) });
if (n.children?.length) flattenDept(n.children, list);
});
};
const openSummaryDialog = async () => {
summaryDialogVisible.value = true;
if (deptOptions.value.length === 0) {
try {
const res = await deptTree();
const tree = res?.data ?? res ?? [];
const list: { label: string; value: string }[] = [];
flattenDept(Array.isArray(tree) ? tree : [], list);
deptOptions.value = list;
} catch (_) {
deptOptions.value = [];
}
}
querySummary();
};
const querySummary = async () => {
summaryLoading.value = true;
try {
const res = await getAgentSummary({
deptCode: summaryQuery.deptCode || undefined,
planStartDate: summaryQuery.planStartDate || undefined,
planEndDate: summaryQuery.planEndDate || undefined,
hasAcceptEvaluation: summaryQuery.hasAcceptEvaluation || undefined,
});
summaryList.value = res?.data && Array.isArray(res.data) ? res.data : [];
} catch (_) {
summaryList.value = [];
} finally {
summaryLoading.value = false;
}
};
const clearSummaryQuery = () => {
summaryQuery.deptCode = '';
summaryQuery.planStartDate = '';
summaryQuery.planEndDate = '';
summaryQuery.hasAcceptEvaluation = '';
querySummary();
};
const formatMoney = (v: any) => {
if (v == null || v === '') return '—';
const n = Number(v);
if (Number.isNaN(n)) return String(v);
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
};
// 定义变量内容
const tableRef = ref()
const formDialogRef = ref()
const searchFormRef = ref()
const showSearch = ref(true)
/**
* 定义响应式表格数据
*/
const state: BasicTableProps = reactive<BasicTableProps>({
pageList: getPage,
queryForm: {
agentName: '',
},
createdIsNeed: true
});
/**
* 使用 useTable 定义表格相关操作
*/
const { getDataList, tableStyle } = useTable(state);
/**
* 重置搜索表单
*/
const handleReset = () => {
searchFormRef.value?.resetFields();
getDataList();
};
/**
* 删除当前行
* @param row - 当前行数据
*/
const handleDelete = async (row: any) => {
try {
await useMessageBox().confirm('确定要删除该记录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
try {
await delObj({"id":row.id});
useMessage().success('删除成功');
getDataList();
} catch (err: any) {
useMessage().error(err.msg || '删除失败');
}
};
</script>
<style scoped lang="scss">
@import '/@/assets/styles/modern-page.scss';
</style>

View File

@@ -50,7 +50,7 @@
<script setup lang="ts" name="PurchasingSchoolLeaderForm">
import { reactive, ref, nextTick } from 'vue'
import { getObj, addObj, editObj } from '/@/api/finance/purchasingschoolleader';
import { getObj, addObj, editObj } from '/@/api/purchase/purchasingschoolleader';
import { useMessage } from '/@/hooks/message';
import orgSelector from '/@/components/OrgSelector/index.vue';

View File

@@ -135,7 +135,7 @@
<script setup lang="ts" name="PurchasingSchoolLeader">
import { ref, reactive, defineAsyncComponent } from 'vue'
import { BasicTableProps, useTable } from "/@/hooks/table";
import { getPage, delObj } from "/@/api/finance/purchasingschoolleader";
import { getPage, delObj } from "/@/api/purchase/purchasingschoolleader";
import { useMessage, useMessageBox } from "/@/hooks/message";
import { List, User, UserFilled, EditPen, Clock, Search } from '@element-plus/icons-vue'

View File

@@ -0,0 +1,182 @@
<template>
<el-dialog
:title="dataForm.id ? '编辑' : '新增'"
v-model="visible"
width="600px"
:close-on-click-modal="false"
draggable>
<el-form
ref="formRef"
:model="dataForm"
:rules="dataRules"
label-width="100px"
v-loading="loading">
<el-form-item label="父级节点" prop="parentCode">
<el-tree-select
v-model="dataForm.parentCode"
:data="parentData"
:props="{ value: 'code', label: 'name', children: 'children' }"
class="w100"
clearable
check-strictly
:render-after-expand="false"
placeholder="请选择父级节点(不选则为根节点)"
/>
</el-form-item>
<el-form-item label="品目编码" prop="code">
<el-input
v-model="dataForm.code"
placeholder="请输入品目编码"
clearable
:disabled="!!dataForm.id" />
</el-form-item>
<el-form-item label="品目名称" prop="name">
<el-input
v-model="dataForm.name"
placeholder="请输入品目名称"
clearable />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="dataForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
clearable />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false"> </el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading"> </el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="PurchasingCategoryForm">
import { reactive, ref, nextTick } from 'vue'
import { getTree, addObj, editObj } from '/@/api/purchase/purchasingcategory';
import { useMessage } from '/@/hooks/message';
// 定义子组件向父组件传值/事件
const emit = defineEmits(['refresh']);
// 定义变量内容
const formRef = ref();
const dataForm = reactive({
id: '',
parentCode: '',
code: '',
name: '',
remark: '',
isMallService: '',
isMallProject: '',
});
const parentData = ref<any[]>([]);
const visible = ref(false);
const loading = ref(false);
const dataRules = ref({
code: [
{ required: true, message: '请输入品目编码', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入品目名称', trigger: 'blur' }
],
});
// 打开弹窗
const openDialog = (type: string, rowData?: any) => {
visible.value = true;
dataForm.id = '';
dataForm.parentCode = '';
dataForm.code = '';
dataForm.name = '';
dataForm.remark = '';
dataForm.isMallService = '';
dataForm.isMallProject = '';
nextTick(() => {
formRef.value?.resetFields();
if (type === 'add' && rowData?.code) {
// 新增时rowData 是父节点数据,设置父级编码
dataForm.parentCode = rowData.code;
} else if (type === 'edit' && rowData) {
// 编辑时rowData 是当前行数据
Object.assign(dataForm, {
id: rowData.id || '',
parentCode: rowData.parentCode || '',
code: rowData.code || '',
name: rowData.name || '',
remark: rowData.remark || '',
isMallService: rowData.isMallService || '',
isMallProject: rowData.isMallProject || '',
});
}
});
getTreeData();
};
// 提交
const onSubmit = async () => {
// 立即设置 loading防止重复点击
if (loading.value) return;
loading.value = true;
try {
const valid = await formRef.value.validate().catch(() => {});
if (!valid) {
loading.value = false;
return false;
}
if (dataForm.id) {
await editObj(dataForm);
useMessage().success('编辑成功');
} else {
await addObj(dataForm);
useMessage().success('新增成功');
}
visible.value = false;
emit('refresh');
} catch (err: any) {
useMessage().error(err.msg || (dataForm.id ? '编辑失败' : '新增失败'));
} finally {
loading.value = false;
}
};
// 从后端获取树形数据
const getTreeData = async () => {
try {
const res = await getTree();
parentData.value = [];
const root = {
code: '',
name: '根节点',
children: [] as any[],
};
if (res.data && Array.isArray(res.data)) {
root.children = res.data;
}
parentData.value.push(root);
} catch (err: any) {
useMessage().error(err.msg || '获取树形数据失败');
parentData.value = [];
}
};
// 暴露变量
defineExpose({
openDialog,
});
</script>
<style scoped>
.w100 {
width: 100%;
}
</style>

View File

@@ -0,0 +1,190 @@
<template>
<div class="modern-page-container">
<div class="page-wrapper">
<!-- 内容卡片 -->
<el-card class="content-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="title-icon"><Document /></el-icon>
采购品目管理
</span>
<div class="header-actions">
<el-button
icon="FolderAdd"
type="primary"
v-auth="'purchase_purchasingcategory_add'"
@click="formDialogRef.openDialog('add')">
新增
</el-button>
<right-toolbar class="ml10" />
</div>
</div>
</template>
<!-- 树形表格懒加载仅首屏加载根节点展开时再加载子节点 -->
<el-table
ref="tableRef"
:data="state.dataList"
v-loading="state.loading"
stripe
lazy
:load="loadTreeNode"
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
class="modern-table"
row-key="code"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }">
<el-table-column type="index" label="序号" width="70" align="center">
<template #header>
<el-icon><List /></el-icon>
</template>
<template #default="{ $index, row }">
{{ getRowIndex($index, row) }}
</template>
</el-table-column>
<el-table-column prop="code" label="品目编码" min-width="150" show-overflow-tooltip>
<template #header>
<el-icon><DocumentCopy /></el-icon>
<span style="margin-left: 4px">品目编码</span>
</template>
</el-table-column>
<el-table-column prop="name" label="品目名称" min-width="200" show-overflow-tooltip>
<template #header>
<el-icon><Document /></el-icon>
<span style="margin-left: 4px">品目名称</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip>
<template #header>
<el-icon><EditPen /></el-icon>
<span style="margin-left: 4px">备注</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" width="150">
<template #default="scope">
<el-button
icon="Edit"
link
type="primary"
v-auth="'purchase_purchasingcategory_edit'"
@click="formDialogRef.openDialog('edit', scope.row)">
编辑
</el-button>
<el-button
icon="Delete"
link
type="danger"
v-auth="'purchase_purchasingcategory_del'"
@click="handleDelete(scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 编辑新增表单对话框 -->
<FormDialog ref="formDialogRef" @refresh="getDataList" />
</div>
</template>
<script setup lang="ts" name="PurchasingCategory">
import { ref, reactive, defineAsyncComponent } from 'vue'
import { BasicTableProps, useTable } from "/@/hooks/table";
import { getTreeRoots, getTreeChildren, delObj } from "/@/api/purchase/purchasingcategory";
import { useMessage, useMessageBox } from "/@/hooks/message";
import { List, Document, DocumentCopy, EditPen } from '@element-plus/icons-vue'
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
// 定义变量内容
const tableRef = ref()
const formDialogRef = ref()
/**
* 查询树根节点(懒加载:首屏只加载根节点)
*/
const queryTreeRoots = () => {
return getTreeRoots().then((res: any) => {
const list = res?.data ?? [];
return { data: Array.isArray(list) ? list : [] };
});
};
/**
* 懒加载子节点:展开某行时按需请求子节点
* @param row 当前行
* @param treeNode 树节点信息
* @param resolve 回调,传入子节点数组
*/
const loadTreeNode = (row: any, treeNode: any, resolve: (data: any[]) => void) => {
const parentCode = row?.code;
if (!parentCode) {
resolve([]);
return;
}
getTreeChildren(parentCode)
.then((res: any) => {
const list = res?.data ?? [];
resolve(Array.isArray(list) ? list : []);
})
.catch(() => resolve([]));
};
/**
* 定义响应式表格数据
*/
const state: BasicTableProps = reactive<BasicTableProps>({
pageList: queryTreeRoots,
queryForm: {},
isPage: false, // 树形表格不分页
});
/**
* 使用 useTable 定义表格相关操作
*/
const { getDataList, tableStyle } = useTable(state);
/**
* 计算行序号(考虑树形结构)
*/
const getRowIndex = (index: number, row: any) => {
// 对于树形表格,序号需要根据实际显示的行来计算
// 这里简化处理,直接返回 index + 1
return index + 1;
};
/**
* 删除当前行
* @param row - 当前行数据
*/
const handleDelete = async (row: any) => {
try {
await useMessageBox().confirm('确定要删除该记录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
try {
// 使用 id 或 code 作为删除参数(根据后端接口决定)
const deleteId = row.id || row.code;
await delObj(deleteId);
useMessage().success('删除成功');
getDataList();
} catch (err: any) {
useMessage().error(err.msg || '删除失败');
}
};
</script>
<style scoped lang="scss">
@import '/@/assets/styles/modern-page.scss';
</style>

View File

@@ -0,0 +1,149 @@
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" >
<el-row :gutter="24">
<el-col :span="12" class="mb20">
<el-form-item label="验收日期" prop="acceptDate">
<el-date-picker
v-model="form.acceptDate"
type="date"
placeholder="请选择"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
:disabled="readonly"
/>
</el-form-item>
</el-col>
<!-- 上传履约验收模版 -->
<el-col :span="12" class="mb20">
<el-form-item label="履约验收文件" prop="templateFileIds">
<upload-file v-model="templateFiles" :limit="1" :file-type="['doc', 'docx', 'pdf']" :data="{ purchaseId: purchaseId || '', fileType: '110' }" upload-file-url="/purchase/purchasingfiles/upload" :disabled="readonly" />
<el-link v-if="!readonly" type="primary" :href="lyysTemplateUrl" :download="lyysTemplateDownloadName" target="_blank" style="margin-top: 8px; display: inline-flex; align-items: center;">
<el-icon><Download /></el-icon>
<span style="margin-left: 4px;">下载{{ lyysTemplateLabel }}</span>
</el-link>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入" :disabled="readonly" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import UploadFile from "/@/components/Upload/index.vue";
/** 项目类型 A:货物 B:工程 C:服务 */
const LYYS_TEMPLATE_MAP: Record<string, { label: string }> = {
A: { label: '履约验收表模板(货物)' },
B: { label: '履约验收表模板(工程)' },
C: { label: '履约验收表模板(服务)' },
}
const props = defineProps<{
modelValue: Record<string, any>
readonly?: boolean
purchaseId?: string
/** 项目类型 A:货物 B:工程 C:服务,用于模版下载 */
projectType?: string
batchNum?: number
}>()
const emit = defineEmits(['update:modelValue'])
const formRef = ref<FormInstance>()
// 文件对象数组用于上传组件显示包含id和fileTitle
const templateFiles = ref<any[]>([])
const projectTypeKey = computed(() => (props.projectType === 'A' || props.projectType === 'B' || props.projectType === 'C' ? props.projectType : 'A'))
const lyysTemplateLabel = computed(() => LYYS_TEMPLATE_MAP[projectTypeKey.value]?.label || LYYS_TEMPLATE_MAP.A.label)
const lyysTemplateDownloadName = computed(() => `${lyysTemplateLabel.value}.docx`)
const lyysTemplateUrl = computed(() => `/templates/lyys-template-${projectTypeKey.value}.docx`)
const form = reactive({
acceptType: '2', // 固定为上传模式
acceptDate: '',
templateFileIds: [] as string[],
remark: '',
})
// 从外部数据初始化(仅在挂载和 modelValue 引用变化时执行)
const initData = () => {
const val = props.modelValue
if (!val) return
form.acceptType = '2'
form.acceptDate = val.acceptDate || ''
form.remark = val.remark || ''
// 处理文件数据:支持 _templateFiles 数组或 templateFileIds
if (val._templateFiles && Array.isArray(val._templateFiles)) {
templateFiles.value = val._templateFiles.map((f: any) => ({
id: f.id,
fileTitle: f.fileTitle,
name: f.fileTitle || '',
url: '',
}))
form.templateFileIds = val._templateFiles.map((f: any) => f.id)
} else if (val.templateFileIds) {
if (typeof val.templateFileIds === 'string') {
const ids = val.templateFileIds.split(',').filter(Boolean)
templateFiles.value = ids.map((id: string) => ({ id: id.trim(), name: '', url: '' }))
form.templateFileIds = ids
} else if (Array.isArray(val.templateFileIds)) {
templateFiles.value = val.templateFileIds.map((item: any) => {
if (typeof item === 'string') return { id: item, name: '', url: '' }
return { id: item.id, fileTitle: item.fileTitle, name: item.fileTitle || '', url: '' }
})
form.templateFileIds = val.templateFileIds.map((item: any) => typeof item === 'string' ? item : item.id).filter(Boolean)
}
} else {
templateFiles.value = []
form.templateFileIds = []
}
}
// 挂载时初始化
onMounted(() => {
initData()
})
// 监听文件变化,更新 form.templateFileIds
watch(templateFiles, (files) => {
if (Array.isArray(files)) {
form.templateFileIds = files.map((f: any) => f.id).filter(Boolean)
} else {
form.templateFileIds = []
}
}, { deep: true })
const rules: FormRules = {
acceptDate: [{ required: true, message: '请选择验收日期', trigger: 'change' }],
}
const validate = () => formRef.value?.validate()
// 获取当前表单数据(供父组件调用)
const getFormData = () => ({
acceptType: form.acceptType,
acceptDate: form.acceptDate,
templateFileIds: [...form.templateFileIds],
remark: form.remark,
})
defineExpose({ validate, form, getFormData, initData })
</script>
<style scoped>
.mb20 {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="140px">
<el-row :gutter="24">
<el-col :span="8" class="mb20">
<el-form-item label="项目名称">
<el-input :model-value="projectName || form.projectName" readonly placeholder="-" />
</el-form-item>
</el-col>
<el-col :span="8" class="mb20">
<el-form-item label="需求部门">
<el-input :model-value="deptName || form.deptName" readonly placeholder="-" />
</el-form-item>
</el-col>
<el-col :span="8" class="mb20">
<el-form-item label="是否签订合同" prop="hasContract">
<el-radio-group v-model="form.hasContract">
<el-radio label="0"></el-radio>
<el-radio label="1"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8" class="mb20" v-if="form.hasContract === '1'">
<el-form-item label="合同" prop="contractId">
<el-select
v-model="form.contractId"
placeholder="请选择合同"
clearable
filterable
style="width: 100%"
:loading="contractLoading"
@visible-change="onContractSelectVisibleChange"
>
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName || item.contractNo || item.id"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8" class="mb20">
<el-form-item label="是否分期验收" prop="isInstallment">
<el-radio-group v-model="form.isInstallment">
<el-radio label="0"></el-radio>
<el-radio label="1"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8" class="mb20" v-if="form.isInstallment === '1'">
<el-form-item label="分期次数" prop="totalPhases">
<el-input-number v-model="form.totalPhases" :min="1" :max="99" placeholder="请输入" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8" class="mb20">
<el-form-item label="供应商名称" prop="supplierName">
<el-input v-model="form.supplierName" placeholder="选择合同后自动带出" clearable />
</el-form-item>
</el-col>
<el-col :span="8" class="mb20">
<el-form-item label="供应商联系人及电话" prop="supplierContact">
<el-input v-model="form.supplierContact" placeholder="请输入" clearable />
</el-form-item>
</el-col>
<el-col :span="8" class="mb20">
<el-form-item label="采购人员" prop="purchaserId">
<org-selector v-model:orgList="purchaserList" type="user" :multiple="false" @update:orgList="onPurchaserChange" />
</el-form-item>
</el-col>
<el-col :span="8" class="mb20">
<el-form-item label="资产管理员" prop="assetAdminId">
<org-selector v-model:orgList="assetAdminList" type="user" :multiple="false" @update:orgList="onAssetAdminChange" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { getContracts } from '/@/api/purchase/purchasingrequisition'
const props = defineProps<{
modelValue: Record<string, any>
projectName?: string
deptName?: string
/** 采购申请ID用于拉取合同列表 */
purchaseId?: string | number
/** 每次打开弹窗时变化,用于强制重置内部 form */
resetKey?: number
}>()
const emit = defineEmits(['update:modelValue'])
const formRef = ref<FormInstance>()
const contractOptions = ref<any[]>([])
const contractLoading = ref(false)
const contractLoaded = ref(false)
const purchaserList = ref<any[]>([])
const assetAdminList = ref<any[]>([])
const form = reactive({
hasContract: '0',
contractId: '',
isInstallment: '0',
totalPhases: 1,
projectName: '',
deptName: '',
supplierName: '',
supplierContact: '',
purchaserId: '',
purchaserName: '',
assetAdminId: '',
assetAdminName: '',
...props.modelValue,
})
const syncFormFromModel = (val: Record<string, any> | undefined) => {
Object.assign(form, val || {})
// 同步采购人员、资产管理员回 org-selector
if (form.purchaserId && form.purchaserName) {
purchaserList.value = [{ id: form.purchaserId, name: form.purchaserName, type: 'user' }]
} else {
purchaserList.value = []
}
if (form.assetAdminId && form.assetAdminName) {
assetAdminList.value = [{ id: form.assetAdminId, name: form.assetAdminName, type: 'user' }]
} else {
assetAdminList.value = []
}
}
const loadContractOptions = async () => {
if (contractLoaded.value || contractLoading.value) return
if (form.hasContract !== '1') return
contractLoading.value = true
try {
const res = await getContracts(props.purchaseId ? { id: props.purchaseId } : {})
const list = res?.data
contractOptions.value = Array.isArray(list) ? list : []
contractLoaded.value = true
// 回显时:列表中含当前合同,用其供应商名称填充(若尚未有值)
if (form.contractId) {
const c = contractOptions.value.find((it: any) => it.id === form.contractId)
if (c?.supplierName) form.supplierName = c.supplierName
}
} catch (_) {
contractOptions.value = []
} finally {
contractLoading.value = false
}
}
const onContractSelectVisibleChange = (visible: boolean) => {
if (visible && form.hasContract === '1' && contractOptions.value.length === 0) {
loadContractOptions()
}
}
watch(
() => props.modelValue,
(val) => {
syncFormFromModel(val)
// 回显已有合同ID时主动加载合同列表以便下拉显示合同名称后端已排除“其他申请”的合同当前申请合同会在列表中
if (form.hasContract === '1' && form.contractId && props.purchaseId && !contractLoaded.value && !contractLoading.value) {
loadContractOptions()
}
},
{ deep: true, immediate: true }
)
// resetKey 变化时强制用 modelValue 覆盖内部 form并重置合同列表以便重新拉取
watch(() => props.resetKey, () => {
syncFormFromModel(props.modelValue)
contractLoaded.value = false
contractOptions.value = []
})
watch(form, () => emit('update:modelValue', { ...form }), { deep: true })
watch(() => form.hasContract, (val) => {
if (val === '1') {
contractLoaded.value = false
loadContractOptions()
} else {
contractOptions.value = []
contractLoaded.value = false
}
})
// 选择合同后,自动带出合同供应商名称
watch(
() => form.contractId,
(val) => {
if (!val) {
form.supplierName = ''
return
}
const c = contractOptions.value.find((it: any) => it.id === val)
if (c && c.supplierName) {
form.supplierName = c.supplierName
}
}
)
onMounted(() => {
if (form.hasContract === '1') {
loadContractOptions()
}
})
const onPurchaserChange = (list: any[]) => {
if (list?.length) {
const u = list[0]
form.purchaserId = u.userId || u.id || ''
form.purchaserName = u.name || u.realName || ''
} else {
form.purchaserId = ''
form.purchaserName = ''
}
}
const onAssetAdminChange = (list: any[]) => {
if (list?.length) {
const u = list[0]
form.assetAdminId = u.userId || u.id || ''
form.assetAdminName = u.name || u.realName || ''
} else {
form.assetAdminId = ''
form.assetAdminName = ''
}
}
const rules: FormRules = {
isInstallment: [{ required: true, message: '请选择是否分期验收', trigger: 'change' }],
totalPhases: [{ required: true, message: '请输入分期次数', trigger: 'blur' }],
}
const validate = () => formRef.value?.validate()
defineExpose({ validate, form })
</script>
<style scoped>
.mb20 {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,471 @@
<template>
<el-dialog
v-model="visible"
title="履约验收"
width="75%"
:close-on-click-modal="false"
destroy-on-close
class="purchasing-accept-modal"
@close="handleClose"
>
<div v-loading="loading" class="modal-body" :key="String(purchaseId)">
<div class="main-tabs">
<div class="main-tab-nav">
<div
class="main-tab-item"
:class="{ active: mainTab === 'common' }"
@click="mainTab = 'common'"
>
公共信息
</div>
<div
class="main-tab-item"
:class="{ active: mainTab === 'batch' }"
@click="mainTab = 'batch'"
>
{{ commonForm?.isInstallment === '0' ? '验收' : '分期验收' }}{{ commonForm?.isInstallment !== '0' && batches.length > 0 ? ` (${batches.length})` : '' }}
</div>
</div>
<div class="main-tab-content">
<div v-show="mainTab === 'common'" class="tab-content">
<AcceptCommonForm
:key="`${purchaseId}-${openToken}`"
:reset-key="openToken"
ref="commonFormRef"
v-model="commonForm"
:purchase-id="purchaseId"
:project-name="applyInfo?.projectName"
:dept-name="applyInfo?.deptName"
/>
</div>
<div v-show="mainTab === 'batch'" class="tab-content">
<div v-if="batches.length > 0">
<div v-show="commonForm?.isInstallment !== '0'" class="batch-tabs">
<div
v-for="b in batches"
:key="b.id"
class="batch-tab-item"
:class="{ active: String(b.batch) === activeTab, disabled: !canEditBatch(b.batch) }"
@click="canEditBatch(b.batch) && (activeTab = String(b.batch))"
>
<span>{{ b.batch }}</span>
<el-tag v-if="isBatchCompleted(b)" type="success" size="small">已填</el-tag>
<el-tag v-else-if="!canEditBatch(b.batch)" type="info" size="small">需先完成上一期</el-tag>
</div>
</div>
<div class="batch-panel">
<AcceptBatchForm
v-for="b in batches"
v-show="String(b.batch) === activeTab"
:key="b.id"
:ref="(el) => setBatchFormRef(b.batch, el)"
:model-value="batchForms[b.batch]"
:readonly="false"
:purchase-id="String(purchaseId)"
:project-type="acceptProjectType"
:batch-num="b.batch"
/>
</div>
</div>
<div v-else class="tip-box">
<el-alert type="info" :closable="false" show-icon>
请先在公共信息中填写并点击保存公共配置系统将按分期次数自动生成验收批次
</el-alert>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<span>
<el-button @click="handleClose"> </el-button>
<el-button
v-if="mainTab === 'common' || batches.length === 0"
type="primary"
@click="saveCommonConfig"
:loading="saving"
>
保存公共配置
</el-button>
<el-button
v-else-if="mainTab === 'batch' && activeBatchId"
type="primary"
@click="saveCurrentBatch"
:loading="saving"
>
保存第{{ activeTab }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useMessage } from '/@/hooks/message'
import {
saveCommonConfig as apiSaveCommonConfig,
getCommonConfigWithBatches,
updateBatch,
getDetail,
} from '/@/api/purchase/purchasingAccept'
import AcceptCommonForm from './AcceptCommonForm.vue'
import AcceptBatchForm from './AcceptBatchForm.vue'
const emit = defineEmits(['refresh'])
const visible = ref(false)
const loading = ref(false)
const saving = ref(false)
const purchaseId = ref<string | number>('')
const applyInfo = ref<any>(null)
const rowProjectType = ref<string>('A')
const batches = ref<any[]>([])
const mainTab = ref('common')
const activeTab = ref('1')
const commonFormRef = ref()
const batchFormRefMap = ref<Record<number, any>>({})
/** 使用 ref 并在每次打开时替换整个对象,确保子组件能感知引用变化并清空 */
const commonForm = ref<Record<string, any>>({})
/** 每次打开自增,用于强制 AcceptCommonForm 重新挂载,确保公共信息彻底清空 */
const openToken = ref(0)
const batchForms = reactive<Record<number, any>>({})
/** 记录哪些期已保存到服务器,用于控制”下一期可填”:只有上一期已保存才允许填下一期 */
const batchSavedFlags = ref<Record<number, boolean>>({})
const setBatchFormRef = (batch: number, el: any) => {
if (el) batchFormRefMap.value[batch] = el
}
const activeBatchId = computed(() => {
const b = batches.value.find((x: any) => String(x.batch) === activeTab.value)
return b?.id || ''
})
/** 项目类型 A:货物 B:工程 C:服务,用于批次表单模版下载 */
const acceptProjectType = computed(() => applyInfo.value?.projectType || rowProjectType.value || 'A')
/** 是否允许编辑该期:第 1 期始终可编辑;第 N 期仅当第 1N-1 期均已保存后才可编辑 */
const canEditBatch = (batch: number) => {
if (batch === 1) return true
for (let i = 1; i < batch; i++) {
if (!batchSavedFlags.value[i]) return false
}
return true
}
/** 该期是否已保存(用于 tab 上显示“已填”标签) */
const isBatchCompleted = (b: any) => {
return !!batchSavedFlags.value[b.batch]
}
const isBatchCompletedByIdx = (batch: number) => {
return !!batchSavedFlags.value[batch]
}
const loadData = async () => {
if (!purchaseId.value) return
const currentId = String(purchaseId.value)
loading.value = true
try {
const configRes = await getCommonConfigWithBatches(currentId)
// 防止快速切换:若已打开其他申请单,忽略本次结果
if (String(purchaseId.value) !== currentId) return
const config = configRes?.data
if (config?.common) {
applyInfo.value = config.common
// 仅当存在已保存批次时,才用接口数据回填公共信息;否则保持 open() 中的默认清空值
if (config?.batches?.length) {
Object.assign(commonForm.value, {
hasContract: config.common.hasContract || '0',
contractId: config.common.contractId || '',
isInstallment: config.common.isInstallment || '0',
totalPhases: config.common.totalPhases || 1,
supplierName: config.common.supplierName || '',
supplierContact: config.common.supplierContact || '',
})
}
}
if (config?.batches?.length) {
batches.value = config.batches.sort((a: any, b: any) => (a.batch || 0) - (b.batch || 0))
activeTab.value = String(batches.value[0]?.batch || '1')
mainTab.value = 'batch'
for (const b of batches.value) {
if (!batchForms[b.batch]) batchForms[b.batch] = {}
}
await loadBatchDetails()
if (String(purchaseId.value) !== currentId) return
} else {
batches.value = []
}
} catch (e: any) {
useMessage().error(e?.msg || '加载失败')
} finally {
loading.value = false
}
}
const loadBatchDetails = async () => {
for (const b of batches.value) {
batchSavedFlags.value[b.batch] = false
}
for (const b of batches.value) {
try {
const res = await getDetail(String(purchaseId.value), b.batch)
const d = res?.data
if (d?.accept) {
// 仅当该期在服务端有验收日期时才视为已保存
const hasSaved = !!d.accept.acceptDate
batchSavedFlags.value[b.batch] = hasSaved
// 优先使用 templateFiles包含id和fileTitle否则降级使用 templateFileIds
let fileIdsStr = ''
if (d.accept.templateFiles && d.accept.templateFiles.length > 0) {
// 使用 templateFiles格式为 {id: string, fileTitle: string}[]
fileIdsStr = d.accept.templateFiles.map((f: any) => f.id).join(',')
} else if (d.accept.templateFileIds) {
// 降级使用 templateFileIds
const fileIds = d.accept.templateFileIds
fileIdsStr = Array.isArray(fileIds) ? fileIds.join(',') : (fileIds || '')
}
batchForms[b.batch] = {
acceptType: '2', // 固定为上传模式
acceptDate: d.accept.acceptDate || '',
remark: d.accept.remark || '',
templateFileIds: fileIdsStr,
// 保存文件信息用于显示
_templateFiles: d.accept.templateFiles || [],
}
// 通知子组件初始化数据
await nextTick()
const batchFormRef = batchFormRefMap.value[b.batch]
if (batchFormRef?.initData) {
batchFormRef.initData()
}
}
} catch (_) {}
}
}
const saveCommonConfig = async () => {
const formRef = commonFormRef.value
const valid = await formRef?.validate?.().catch(() => false)
if (!valid) return
// 直接从子组件 form 读取,确保拿到用户填写的最新值(避免 v-model 同步延迟)
const form = formRef?.form || commonForm.value
const isInstallment = form.isInstallment === '1' || form.isInstallment === 1
if (isInstallment && (!form.totalPhases || form.totalPhases < 1)) {
useMessage().error('请填写分期次数')
return
}
saving.value = true
try {
await apiSaveCommonConfig({
purchaseId: String(purchaseId.value),
hasContract: form.hasContract ?? '0',
contractId: form.contractId ?? '',
isInstallment: form.isInstallment ?? '0',
totalPhases: isInstallment ? (Number(form.totalPhases) || 1) : 1,
supplierName: String(form.supplierName ?? ''),
supplierContact: String(form.supplierContact ?? ''),
purchaserId: String(form.purchaserId ?? ''),
purchaserName: String(form.purchaserName ?? ''),
assetAdminId: String(form.assetAdminId ?? ''),
assetAdminName: String(form.assetAdminName ?? ''),
})
useMessage().success('保存成功')
await loadData()
} catch (e: any) {
useMessage().error(e?.msg || '保存失败')
} finally {
saving.value = false
}
}
const saveCurrentBatch = async () => {
const curBatch = Number(activeTab.value)
const batchFormRef = batchFormRefMap.value[curBatch]
const valid = await batchFormRef?.validate?.().catch(() => false)
if (!valid) return
const b = batches.value.find((x: any) => String(x.batch) === activeTab.value)
if (!b?.id) return
// 从子组件获取表单数据
const formData = batchFormRef?.getFormData?.() || batchFormRef?.form
if (!formData) return
if (!formData.acceptDate) {
useMessage().error('请选择验收日期')
return
}
// templateFileIds: 提取ID数组
let fileIds: string[] = []
if (formData.templateFileIds) {
if (Array.isArray(formData.templateFileIds)) {
fileIds = formData.templateFileIds.map((item: any) => {
if (typeof item === 'string') return item
if (item && item.id) return item.id
return null
}).filter(Boolean)
} else if (typeof formData.templateFileIds === 'string') {
fileIds = formData.templateFileIds.split(',').map((s: string) => s.trim()).filter(Boolean)
}
}
saving.value = true
try {
await updateBatch({
id: b.id,
purchaseId: String(purchaseId.value),
acceptType: '2', // 固定为上传模式
acceptDate: formData.acceptDate,
remark: formData.remark,
templateFileIds: fileIds,
})
useMessage().success('保存成功')
batchSavedFlags.value[curBatch] = true
await loadData()
} catch (e: any) {
useMessage().error(e?.msg || '保存失败')
} finally {
saving.value = false
}
}
const handleClose = () => {
visible.value = false
emit('refresh')
}
const DEFAULT_COMMON_FORM = {
hasContract: '0',
contractId: '',
isInstallment: '0',
totalPhases: 1,
supplierName: '',
supplierContact: '',
purchaserId: '',
purchaserName: '',
assetAdminId: '',
assetAdminName: '',
}
/** 将弹窗内所有内容恢复为初始空值(替换整个对象以确保引用变化) */
const resetAllToDefault = () => {
openToken.value++
commonForm.value = { ...DEFAULT_COMMON_FORM }
applyInfo.value = null
mainTab.value = 'common'
activeTab.value = '1'
batchFormRefMap.value = {}
batches.value = []
Object.keys(batchForms).forEach((k) => delete batchForms[Number(k)])
batchSavedFlags.value = {}
}
const open = async (row: any) => {
purchaseId.value = row?.id ?? ''
rowProjectType.value = row?.projectType || 'A'
// 1. 先将弹窗内所有内容恢复为初始空值
resetAllToDefault()
// 2. 显示弹窗并开启 loading避免接口返回前展示旧数据
visible.value = true
loading.value = true
// 3. 等待 Vue 完成渲染,确保子组件已接收并展示空值
await nextTick()
await nextTick()
// 4. 再进行接口查询并覆盖
await loadData()
}
defineExpose({ open })
</script>
<style scoped>
.modal-body {
padding: 0;
max-height: 70vh;
overflow-y: auto;
overflow-x: hidden;
}
.main-tab-nav {
display: flex;
gap: 4px;
margin-bottom: 16px;
border-bottom: 1px solid var(--el-border-color);
}
.main-tab-item {
padding: 12px 20px;
cursor: pointer;
color: var(--el-text-color-regular);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all 0.2s;
}
.main-tab-item:hover {
color: var(--el-color-primary);
}
.main-tab-item.active {
color: var(--el-color-primary);
font-weight: 600;
border-bottom-color: var(--el-color-primary);
}
.main-tab-content {
padding-top: 4px;
}
.tab-content {
min-height: 200px;
display: block;
}
.tip-box {
padding: 20px 0;
}
.batch-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.batch-tab-item {
padding: 8px 16px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.batch-tab-item:hover:not(.disabled) {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.batch-tab-item.active {
background: var(--el-color-primary);
border-color: var(--el-color-primary);
color: #fff;
}
.batch-tab-item.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.batch-panel {
min-height: 200px;
}
</style>
<style>
/* 弹窗横向滚动修复,需非 scoped 以影响 el-dialog */
.purchasing-accept-modal .el-dialog__body {
overflow-x: hidden;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,288 @@
<template>
<el-dialog
v-model="visible"
:title="'处理项目 - ' + (projectInfo.purchaseNo || '')"
width="900px"
:close-on-click-modal="false"
destroy-on-close
class="agent-doc-dialog">
<el-tabs v-model="activeTab" type="border-card">
<!-- Tab 1: 项目基本信息 -->
<el-tab-pane label="项目信息" name="info">
<el-descriptions :column="2" border>
<el-descriptions-item label="采购编号">{{ projectInfo.purchaseNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="项目名称">{{ projectInfo.projectName || '-' }}</el-descriptions-item>
<el-descriptions-item label="文件状态">
<el-tag :type="getStatusType(projectInfo.status)">{{ getStatusLabel(projectInfo.status) }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<!-- Tab 2: 采购需求文件 -->
<el-tab-pane label="采购需求文件" name="requirement">
<el-table :data="requirementFiles" v-loading="requirementLoading" stripe>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="fileName" label="文件名称" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="120" align="center">
<template #default="scope">
<el-button type="primary" link icon="Download" @click="handleDownload(scope.row)">下载</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!requirementLoading && requirementFiles.length === 0" description="暂无采购需求文件" />
</el-tab-pane>
<!-- Tab 3: 上传招标文件 -->
<el-tab-pane label="上传招标文件" name="upload">
<div class="upload-section">
<el-alert type="info" :closable="false" class="mb-4">
<template #title>
<span>当前状态<el-tag :type="getStatusType(projectInfo.status)" size="small">{{ getStatusLabel(projectInfo.status) }}</el-tag></span>
</template>
</el-alert>
<!-- 文件上传 -->
<el-upload
ref="uploadRef"
:action="uploadAction"
:headers="uploadHeaders"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
:file-list="fileList"
:auto-upload="false"
:limit="1"
accept=".doc,.docx,.pdf"
class="upload-demo">
<el-button type="primary">选择文件</el-button>
<template #tip>
<div class="el-upload__tip">支持 .doc, .docx, .pdf 格式文件大小不超过 50MB</div>
</template>
</el-upload>
<el-button
type="success"
:loading="uploadSubmitting"
:disabled="fileList.length === 0 || !canUpload"
@click="handleUploadSubmit"
class="mt-4">
{{ projectInfo.status === 'RETURNED' ? '重新上传招标文件' : '上传招标文件' }}
</el-button>
<el-alert v-if="!canUpload" type="warning" :closable="false" class="mt-4">
<template #title>
<span v-if="projectInfo.status === 'ASSET_REVIEWING'">文件审核中请等待审核结果</span>
<span v-else-if="projectInfo.status === 'DEPT_REVIEWING'">文件审核中请等待审核结果</span>
<span v-else-if="projectInfo.status === 'AUDIT_REVIEWING'">文件审核中请等待审核结果</span>
<span v-else-if="projectInfo.status === 'ASSET_CONFIRMING'">文件审核中请等待确认</span>
<span v-else-if="projectInfo.status === 'COMPLETED'">文件审核已完成</span>
<span v-else>当前状态不允许上传</span>
</template>
</el-alert>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="AgentDocDialog">
import { ref, computed } from 'vue'
import { useMessage } from '/@/hooks/message'
import { Session } from '/@/utils/storage'
import { getAgentRequirementFiles, getAgentApplyDetail, uploadAgentDoc, reuploadAgentDoc, getDocList } from '/@/api/purchase/purchasingrequisition'
import type { UploadInstance, UploadProps, UploadUserFile } from 'element-plus'
const emit = defineEmits(['refresh'])
const visible = ref(false)
const activeTab = ref('info')
const projectInfo = ref<any>({})
const requirementFiles = ref<any[]>([])
const requirementLoading = ref(false)
const uploadSubmitting = ref(false)
const uploadRef = ref<UploadInstance>()
const fileList = ref<UploadUserFile[]>([])
// 上传配置
const uploadAction = computed(() => {
const baseUrl = import.meta.env.VITE_API_URL || ''
return `${baseUrl}/admin/sys-file/upload`
})
const uploadHeaders = computed(() => {
const token = Session.getToken()
return {
Authorization: `Bearer ${token}`,
'TENANT-ID': Session.getTenant() || '1'
}
})
// 是否可以上传
const canUpload = computed(() => {
const status = projectInfo.value.status
return status === 'PENDING_UPLOAD' || status === 'RETURNED'
})
const open = async (row: any) => {
projectInfo.value = { ...row }
visible.value = true
activeTab.value = 'info'
fileList.value = []
await loadProjectDetail()
await loadRequirementFiles()
}
const loadProjectDetail = async () => {
try {
const res = await getAgentApplyDetail(projectInfo.value.applyId)
if (res?.data) {
projectInfo.value = { ...projectInfo.value, ...res.data }
}
} catch (e: any) {
console.error('加载项目详情失败', e)
}
}
const loadRequirementFiles = async () => {
requirementLoading.value = true
try {
const res = await getAgentRequirementFiles(projectInfo.value.applyId)
requirementFiles.value = res?.data || []
} catch (e: any) {
requirementFiles.value = []
} finally {
requirementLoading.value = false
}
}
const handleDownload = (row: any) => {
// TODO: 实现下载功能
const downloadUrl = `/purchase/purchasingdoc/download/${row.id}`
window.open(downloadUrl, '_blank')
}
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
const allowedTypes = ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/pdf']
const allowedExtensions = ['.doc', '.docx', '.pdf']
const fileExt = rawFile.name.substring(rawFile.name.lastIndexOf('.')).toLowerCase()
if (!allowedTypes.includes(rawFile.type) && !allowedExtensions.includes(fileExt)) {
useMessage().error('只能上传 .doc, .docx, .pdf 格式的文件')
return false
}
if (rawFile.size / 1024 / 1024 > 50) {
useMessage().error('文件大小不能超过 50MB')
return false
}
return true
}
const handleUploadSuccess: UploadProps['onSuccess'] = (response: any, uploadFile: any) => {
if (response?.code === 0 || response?.code === 200) {
const fileUrl = response.data?.url || response.data?.fileUrl || response.data?.filePath
const fileName = uploadFile.name
submitUpload(fileName, fileUrl)
} else {
useMessage().error(response?.msg || '上传失败')
uploadSubmitting.value = false
}
}
const handleUploadError: UploadProps['onError'] = (error: any) => {
useMessage().error('文件上传失败:' + (error?.message || '未知错误'))
uploadSubmitting.value = false
}
const submitUpload = async (fileName: string, filePath: string) => {
try {
const data = {
applyId: projectInfo.value.applyId,
fileName,
filePath
}
if (projectInfo.value.status === 'RETURNED') {
await reuploadAgentDoc(data)
} else {
await uploadAgentDoc(data)
}
useMessage().success('招标文件上传成功')
emit('refresh')
await loadProjectDetail()
fileList.value = []
} catch (e: any) {
useMessage().error(e?.msg || '上传失败')
} finally {
uploadSubmitting.value = false
}
}
const handleUploadSubmit = async () => {
if (fileList.value.length === 0) {
useMessage().warning('请先选择文件')
return
}
uploadSubmitting.value = true
uploadRef.value?.submit()
}
const handleClose = () => {
visible.value = false
}
const getStatusType = (status: string) => {
const typeMap: Record<string, string> = {
'PENDING_UPLOAD': 'info',
'ASSET_REVIEWING': 'warning',
'DEPT_REVIEWING': 'warning',
'AUDIT_REVIEWING': 'warning',
'ASSET_CONFIRMING': 'primary',
'COMPLETED': 'success',
'RETURNED': 'danger'
}
return typeMap[status] || 'info'
}
const getStatusLabel = (status: string) => {
const labelMap: Record<string, string> = {
'PENDING_UPLOAD': '待上传',
'ASSET_REVIEWING': '资产管理处审核中',
'DEPT_REVIEWING': '需求部门审核中',
'AUDIT_REVIEWING': '内审部门审核中',
'ASSET_CONFIRMING': '资产管理处确认中',
'COMPLETED': '已完成',
'RETURNED': '已退回'
}
return labelMap[status] || '-'
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.agent-doc-dialog {
:deep(.el-dialog__body) {
padding: 16px 20px;
}
}
.upload-section {
padding: 16px 0;
}
.mb-4 {
margin-bottom: 16px;
}
.mt-4 {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<div class="modern-page-container">
<div class="page-wrapper">
<!-- 搜索表单卡片 -->
<el-card v-show="showSearch" class="search-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="title-icon"><Search /></el-icon>
筛选条件
</span>
</div>
</template>
<el-form :model="state.queryForm" ref="searchFormRef" :inline="true" @keyup.enter="getDataList" class="search-form">
<el-form-item label="采购编号" prop="purchaseNo">
<el-input
v-model="state.queryForm.purchaseNo"
placeholder="请输入采购编号"
clearable
style="width: 200px" />
</el-form-item>
<el-form-item label="项目名称" prop="projectName">
<el-input
v-model="state.queryForm.projectName"
placeholder="请输入项目名称"
clearable
style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="getDataList">查询</el-button>
<el-button icon="Refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 内容卡片 -->
<el-card class="content-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="title-icon"><DocumentCopy /></el-icon>
招标代理工作台
</span>
<div class="header-actions">
<right-toolbar v-model:showSearch="showSearch" class="ml10" @queryTable="getDataList" />
</div>
</div>
</template>
<!-- 表格 -->
<el-table
ref="tableRef"
:data="state.dataList"
v-loading="state.loading"
stripe
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
class="modern-table">
<el-table-column type="index" label="序号" width="70" align="center">
<template #header>
<el-icon><List /></el-icon>
</template>
</el-table-column>
<el-table-column prop="purchaseNo" label="采购编号" min-width="140" show-overflow-tooltip />
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="文件状态" width="140" align="center">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ getStatusLabel(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" width="120">
<template #default="scope">
<el-button type="primary" link icon="View" @click="handleView(scope.row)">
处理
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination
v-if="state.pagination && state.pagination.total && state.pagination.total > 0"
:total="state.pagination.total"
:current="state.pagination.current"
:size="state.pagination.size"
@sizeChange="sizeChangeHandle"
@currentChange="currentChangeHandle"
/>
</el-card>
</div>
<!-- 处理弹窗 -->
<AgentDocDialog ref="agentDocDialogRef" @refresh="getDataList" />
</div>
</template>
<script setup lang="ts" name="PurchasingAgentDoc">
import { ref, reactive, defineAsyncComponent, onMounted } from 'vue'
import { BasicTableProps, useTable } from "/@/hooks/table";
import { useMessage } from "/@/hooks/message";
import { getAgentPendingList } from "/@/api/purchase/purchasingrequisition";
import { Search, DocumentCopy, List } from '@element-plus/icons-vue'
// 引入组件
const AgentDocDialog = defineAsyncComponent(() => import('./AgentDocDialog.vue'));
const agentDocDialogRef = ref()
const searchFormRef = ref()
const showSearch = ref(true)
const state: BasicTableProps = reactive<BasicTableProps>({
pageList: getAgentPendingList,
queryForm: {
purchaseNo: '',
projectName: '',
},
createdIsNeed: true
});
const { getDataList, tableStyle, sizeChangeHandle, currentChangeHandle } = useTable(state);
const handleReset = () => {
searchFormRef.value?.resetFields();
getDataList();
};
const getStatusType = (status: string) => {
const typeMap: Record<string, string> = {
'PENDING_UPLOAD': 'info',
'ASSET_REVIEWING': 'warning',
'DEPT_REVIEWING': 'warning',
'AUDIT_REVIEWING': 'warning',
'ASSET_CONFIRMING': 'primary',
'COMPLETED': 'success',
'RETURNED': 'danger'
};
return typeMap[status] || 'info';
};
const getStatusLabel = (status: string) => {
const labelMap: Record<string, string> = {
'PENDING_UPLOAD': '待上传',
'ASSET_REVIEWING': '资产管理处审核中',
'DEPT_REVIEWING': '需求部门审核中',
'AUDIT_REVIEWING': '内审部门审核中',
'ASSET_CONFIRMING': '资产管理处确认中',
'COMPLETED': '已完成',
'RETURNED': '已退回'
};
return labelMap[status] || '-';
};
const handleView = (row: any) => {
agentDocDialogRef.value?.open(row);
};
onMounted(() => {
// 页面加载时的初始化逻辑
});
</script>
<style scoped lang="scss">
@import '/@/assets/styles/modern-page.scss';
</style>

View File

@@ -0,0 +1,65 @@
<template>
<el-table :data="records" stripe v-loading="loading" max-height="400">
<el-table-column prop="operateTypeDesc" label="操作类型" width="100" align="center">
<template #default="scope">
<el-tag :type="getOperateTypeStyle(scope.row.operateType)">
{{ scope.row.operateTypeDesc }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="operateRoleDesc" label="操作角色" width="100" />
<el-table-column prop="operateByName" label="操作人" width="100" />
<el-table-column prop="currentVersion" label="文件版本" width="80" align="center" />
<el-table-column prop="remark" label="批注意见" min-width="200" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.remark || '-' }}
</template>
</el-table-column>
<el-table-column prop="operateTime" label="操作时间" width="160" />
</el-table>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getAuditRecords } from '/@/api/purchase/purchasingdoc'
const props = defineProps<{
applyId: number | string
}>()
const records = ref<any[]>([])
const loading = ref(false)
const loadRecords = async () => {
if (!props.applyId) return
loading.value = true
try {
const res = await getAuditRecords(props.applyId)
records.value = res.data || []
} catch (e) {
records.value = []
} finally {
loading.value = false
}
}
const refresh = () => {
loadRecords()
}
const getOperateTypeStyle = (type: string) => {
const styleMap: Record<string, string> = {
'UPLOAD': 'primary',
'CONFIRM': 'success',
'RETURN': 'warning',
'COMPLETE': 'success'
}
return styleMap[type] || 'info'
}
watch(() => props.applyId, () => {
loadRecords()
}, { immediate: true })
defineExpose({ refresh })
</script>

View File

@@ -0,0 +1,323 @@
<template>
<el-dialog
v-model="visible"
title="采购文件审核"
width="900px"
destroy-on-close
@close="handleClose">
<el-tabs v-model="activeTab">
<!-- 项目信息 -->
<el-tab-pane label="项目信息" name="info">
<el-descriptions :column="2" border>
<el-descriptions-item label="采购编号">{{ applyInfo.purchaseNo }}</el-descriptions-item>
<el-descriptions-item label="项目名称">{{ applyInfo.projectName }}</el-descriptions-item>
<el-descriptions-item label="需求部门">{{ applyInfo.deptName }}</el-descriptions-item>
<el-descriptions-item label="预算金额">{{ applyInfo.budget ? Number(applyInfo.budget).toLocaleString() + '元' : '-' }}</el-descriptions-item>
<el-descriptions-item label="招标代理">{{ applyInfo.agentName || '-' }}</el-descriptions-item>
<el-descriptions-item label="审核状态">
<el-tag :type="getStatusType(applyInfo.docAuditStatus)">
{{ getStatusLabel(applyInfo.docAuditStatus) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<!-- 采购需求文件 -->
<el-tab-pane label="采购需求文件" name="requirement">
<el-table :data="requirementFiles" stripe v-loading="requirementLoading">
<el-table-column prop="fileTitle" label="文件名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="fileType" label="文件类型" width="120">
<template #default="scope">
{{ getFileTypeLabel(scope.row.fileType) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="scope">
<el-button type="primary" link icon="Download" @click="handleDownloadRequirement(scope.row)">
下载
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 采购文件 -->
<el-tab-pane label="采购文件" name="doc">
<div class="doc-header">
<el-button v-if="canUpload" type="primary" icon="Upload" @click="handleUpload">
{{ applyInfo.docAuditStatus === 'RETURNED' ? '重新上传' : '上传文件' }}
</el-button>
</div>
<el-table :data="docList" stripe v-loading="docLoading">
<el-table-column prop="fileName" label="文件名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="version" label="版本" width="80" align="center" />
<el-table-column prop="uploadByName" label="上传人" width="100" />
<el-table-column prop="uploadTime" label="上传时间" width="160" />
<el-table-column label="操作" width="100" align="center">
<template #default="scope">
<el-button type="primary" link icon="Download" @click="handleDownloadDoc(scope.row)">
下载
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 审核记录 -->
<el-tab-pane label="审核记录" name="audit">
<AuditRecordList :apply-id="applyInfo.id" ref="auditRecordListRef" />
</el-tab-pane>
</el-tabs>
<!-- 操作区域 -->
<template #footer>
<div class="dialog-footer">
<el-button v-if="canConfirm" type="success" @click="handleConfirm">确认无误</el-button>
<el-button v-if="canReturn" type="warning" @click="handleReturn">退回修改</el-button>
<el-button v-if="canComplete" type="primary" @click="handleComplete">确认流程结束</el-button>
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
<!-- 退回原因弹窗 -->
<el-dialog v-model="returnDialogVisible" title="退回原因" width="400px" append-to-body>
<el-form>
<el-form-item label="退回原因">
<el-input v-model="returnRemark" type="textarea" :rows="3" placeholder="请输入退回原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="returnDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitReturn">确定</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, defineAsyncComponent } from 'vue'
import { useMessage, useMessageBox } from '/@/hooks/message'
import { getObj, getApplyFiles } from '/@/api/purchase/purchasingrequisition'
import { getDocList, uploadDoc, reuploadDoc, confirmDoc, returnDoc, completeDoc, getAvailableActions, getDocDownloadUrl } from '/@/api/purchase/purchasingdoc'
import other from '/@/utils/other'
const AuditRecordList = defineAsyncComponent(() => import('./AuditRecordList.vue'));
const emit = defineEmits(['refresh']);
const visible = ref(false)
const activeTab = ref('info')
const applyInfo = ref<any>({})
const requirementFiles = ref<any[]>([])
const requirementLoading = ref(false)
const docList = ref<any[]>([])
const docLoading = ref(false)
const auditRecordListRef = ref()
const availableActions = ref<string[]>([])
const returnDialogVisible = ref(false)
const returnRemark = ref('')
const canUpload = computed(() => availableActions.value.includes('upload'))
const canConfirm = computed(() => availableActions.value.includes('confirm'))
const canReturn = computed(() => availableActions.value.includes('return'))
const canComplete = computed(() => availableActions.value.includes('complete'))
const open = async (row: any) => {
visible.value = true
activeTab.value = 'info'
applyInfo.value = {}
requirementFiles.value = []
docList.value = []
returnRemark.value = ''
// 加载详情
try {
const res = await getObj(row.id)
applyInfo.value = res.data || res
} catch (e: any) {
useMessage().error(e?.msg || '加载项目信息失败')
return
}
// 加载可执行操作
try {
const actionsRes = await getAvailableActions(row.id)
availableActions.value = actionsRes.data || []
} catch (e) {
availableActions.value = []
}
// 加载采购需求文件
loadRequirementFiles()
// 加载采购文件
loadDocList()
}
const loadRequirementFiles = async () => {
if (!applyInfo.value.id) return
requirementLoading.value = true
try {
const res = await getApplyFiles(applyInfo.value.id)
const files = res.data || res || []
// 过滤采购需求文件fileType=120
requirementFiles.value = files.filter((f: any) => f.fileType === '120')
} catch (e) {
requirementFiles.value = []
} finally {
requirementLoading.value = false
}
}
const loadDocList = async () => {
if (!applyInfo.value.id) return
docLoading.value = true
try {
const res = await getDocList(applyInfo.value.id)
docList.value = res.data || []
} catch (e) {
docList.value = []
} finally {
docLoading.value = false
}
}
const handleUpload = () => {
// 触发文件上传
const input = document.createElement('input')
input.type = 'file'
input.accept = '*/*'
input.onchange = async (e: any) => {
const file = e.target.files[0]
if (!file) return
// TODO: 实现文件上传到OSS然后调用上传接口
useMessage().info('文件上传功能需要配合OSS实现')
}
input.click()
}
const handleDownloadRequirement = (row: any) => {
if (row.remark) {
const url = `/purchase/purchasingfiles/download?fileName=${encodeURIComponent(row.remark)}&fileTitle=${encodeURIComponent(row.fileTitle)}`
other.downBlobFile(url, {}, row.fileTitle)
}
}
const handleDownloadDoc = (row: any) => {
const url = getDocDownloadUrl(row.id)
other.downBlobFile(url, {}, row.fileName)
}
const handleConfirm = async () => {
try {
await useMessageBox().confirm('确定要确认该采购文件无误吗?')
} catch {
return
}
try {
await confirmDoc({ applyId: applyInfo.value.id })
useMessage().success('确认成功')
emit('refresh')
loadDocList()
auditRecordListRef.value?.refresh()
// 重新加载可执行操作
const actionsRes = await getAvailableActions(applyInfo.value.id)
availableActions.value = actionsRes.data || []
} catch (e: any) {
useMessage().error(e?.msg || '确认失败')
}
}
const handleReturn = () => {
returnRemark.value = ''
returnDialogVisible.value = true
}
const submitReturn = async () => {
try {
await returnDoc({ applyId: applyInfo.value.id, remark: returnRemark.value })
useMessage().success('退回成功')
returnDialogVisible.value = false
emit('refresh')
loadDocList()
auditRecordListRef.value?.refresh()
// 重新加载可执行操作
const actionsRes = await getAvailableActions(applyInfo.value.id)
availableActions.value = actionsRes.data || []
} catch (e: any) {
useMessage().error(e?.msg || '退回失败')
}
}
const handleComplete = async () => {
try {
await useMessageBox().confirm('确定要确认流程结束吗?')
} catch {
return
}
try {
await completeDoc(applyInfo.value.id)
useMessage().success('流程已结束')
emit('refresh')
loadDocList()
auditRecordListRef.value?.refresh()
// 重新加载可执行操作
const actionsRes = await getAvailableActions(applyInfo.value.id)
availableActions.value = actionsRes.data || []
} catch (e: any) {
useMessage().error(e?.msg || '操作失败')
}
}
const handleClose = () => {
visible.value = false
}
const getStatusType = (status: string) => {
const typeMap: Record<string, string> = {
'PENDING_UPLOAD': 'info',
'ASSET_REVIEWING': 'warning',
'DEPT_REVIEWING': 'warning',
'AUDIT_REVIEWING': 'warning',
'ASSET_CONFIRMING': 'primary',
'COMPLETED': 'success',
'RETURNED': 'danger'
}
return typeMap[status] || 'info'
}
const getStatusLabel = (status: string) => {
const labelMap: Record<string, string> = {
'PENDING_UPLOAD': '待上传',
'ASSET_REVIEWING': '资产管理处审核中',
'DEPT_REVIEWING': '需求部门审核中',
'AUDIT_REVIEWING': '内审部门审核中',
'ASSET_CONFIRMING': '资产管理处确认中',
'COMPLETED': '已完成',
'RETURNED': '已退回'
}
return labelMap[status] || '-'
}
const getFileTypeLabel = (type: string) => {
const labelMap: Record<string, string> = {
'120': '采购需求表',
'130': '采购文件'
}
return labelMap[type] || type
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.doc-header {
margin-bottom: 16px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="modern-page-container">
<div class="page-wrapper">
<!-- 搜索表单卡片 -->
<el-card v-show="showSearch" class="search-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="title-icon"><Search /></el-icon>
筛选条件
</span>
</div>
</template>
<el-form :model="state.queryForm" ref="searchFormRef" :inline="true" @keyup.enter="getDataList" class="search-form">
<el-form-item label="采购编号" prop="purchaseNo">
<el-input
v-model="state.queryForm.purchaseNo"
placeholder="请输入采购编号"
clearable
style="width: 200px" />
</el-form-item>
<el-form-item label="项目名称" prop="projectName">
<el-input
v-model="state.queryForm.projectName"
placeholder="请输入项目名称"
clearable
style="width: 200px" />
</el-form-item>
<el-form-item label="审核状态" prop="docAuditStatus">
<el-select
v-model="state.queryForm.docAuditStatus"
placeholder="请选择审核状态"
clearable
style="width: 200px">
<el-option label="待上传" value="PENDING_UPLOAD" />
<el-option label="资产管理处审核中" value="ASSET_REVIEWING" />
<el-option label="需求部门审核中" value="DEPT_REVIEWING" />
<el-option label="内审部门审核中" value="AUDIT_REVIEWING" />
<el-option label="资产管理处确认中" value="ASSET_CONFIRMING" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="已退回" value="RETURNED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="getDataList">查询</el-button>
<el-button icon="Refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 内容卡片 -->
<el-card class="content-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="title-icon"><DocumentChecked /></el-icon>
采购文件审核
</span>
<div class="header-actions">
<right-toolbar v-model:showSearch="showSearch" class="ml10" @queryTable="getDataList" />
</div>
</div>
</template>
<!-- 表格 -->
<el-table
ref="tableRef"
:data="state.dataList"
v-loading="state.loading"
stripe
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
class="modern-table">
<el-table-column type="index" label="序号" width="70" align="center">
<template #header>
<el-icon><List /></el-icon>
</template>
</el-table-column>
<el-table-column prop="purchaseNo" label="采购编号" min-width="140" show-overflow-tooltip />
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="deptName" label="需求部门" min-width="150" show-overflow-tooltip />
<el-table-column prop="budget" label="预算金额(元)" width="120" align="right">
<template #default="scope">
{{ scope.row.budget ? Number(scope.row.budget).toLocaleString() : '-' }}
</template>
</el-table-column>
<el-table-column prop="docAuditStatus" label="审核状态" width="140" align="center">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.docAuditStatus)">
{{ getStatusLabel(scope.row.docAuditStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="currentDocVersion" label="当前版本" width="100" align="center">
<template #default="scope">
{{ scope.row.currentDocVersion || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" width="120">
<template #default="scope">
<el-button type="primary" link icon="View" @click="handleAudit(scope.row)">
审核
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination
v-if="state.pagination && state.pagination.total && state.pagination.total > 0"
:total="state.pagination.total"
:current="state.pagination.current"
:size="state.pagination.size"
@sizeChange="sizeChangeHandle"
@currentChange="currentChangeHandle"
/>
</el-card>
</div>
<!-- 审核弹窗 -->
<DocAuditDialog ref="docAuditDialogRef" @refresh="getDataList" />
</div>
</template>
<script setup lang="ts" name="PurchasingDocAudit">
import { ref, reactive, defineAsyncComponent, onMounted } from 'vue'
import { BasicTableProps, useTable } from "/@/hooks/table";
import { useMessage } from "/@/hooks/message";
import { getPage } from "/@/api/purchase/purchasingrequisition";
import { Search, DocumentChecked, List } from '@element-plus/icons-vue'
// 引入组件
const DocAuditDialog = defineAsyncComponent(() => import('./DocAuditDialog.vue'));
const docAuditDialogRef = ref()
const searchFormRef = ref()
const showSearch = ref(true)
const state: BasicTableProps = reactive<BasicTableProps>({
pageList: getPage,
queryForm: {
purchaseNo: '',
projectName: '',
docAuditStatus: '',
},
createdIsNeed: true
});
const { getDataList, tableStyle, sizeChangeHandle, currentChangeHandle } = useTable(state);
const handleReset = () => {
searchFormRef.value?.resetFields();
getDataList();
};
const getStatusType = (status: string) => {
const typeMap: Record<string, string> = {
'PENDING_UPLOAD': 'info',
'ASSET_REVIEWING': 'warning',
'DEPT_REVIEWING': 'warning',
'AUDIT_REVIEWING': 'warning',
'ASSET_CONFIRMING': 'primary',
'COMPLETED': 'success',
'RETURNED': 'danger'
};
return typeMap[status] || 'info';
};
const getStatusLabel = (status: string) => {
const labelMap: Record<string, string> = {
'PENDING_UPLOAD': '待上传',
'ASSET_REVIEWING': '资产管理处审核中',
'DEPT_REVIEWING': '需求部门审核中',
'AUDIT_REVIEWING': '内审部门审核中',
'ASSET_CONFIRMING': '资产管理处确认中',
'COMPLETED': '已完成',
'RETURNED': '已退回'
};
return labelMap[status] || '-';
};
const handleAudit = (row: any) => {
docAuditDialogRef.value?.open(row);
};
onMounted(() => {
// 页面加载时的初始化逻辑
});
</script>
<style scoped lang="scss">
@import '/@/assets/styles/modern-page.scss';
</style>

View File

@@ -0,0 +1,121 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="90%"
:style="{ maxWidth: '1600px' }"
:close-on-click-modal="false"
destroy-on-close
class="form-iframe-dialog"
@close="handleClose"
>
<div class="form-iframe-content">
<iframe
ref="iframeRef"
:src="iframeSrc"
frameborder="0"
class="form-iframe"
/>
</div>
</el-dialog>
</template>
<script setup lang="ts" name="PurchasingRequisitionForm">
import { ref, computed, watch } from 'vue'
const props = defineProps<{
dictData?: Record<string, any>
}>()
const emit = defineEmits<{
(e: 'refresh'): void
}>()
const visible = ref(false)
const iframeRef = ref<HTMLIFrameElement>()
const mode = ref<'add' | 'edit' | 'view'>('add')
const rowId = ref<string | number>('')
const dialogTitle = computed(() => {
const titles = {
add: '新增采购申请',
edit: '编辑采购申请',
view: '查看采购申请',
}
return titles[mode.value] || titles.add
})
const iframeSrc = computed(() => {
const baseUrl = window.location.origin + window.location.pathname
let src = `${baseUrl}#/finance/purchasingrequisition/add`
if (mode.value !== 'add' && rowId.value) {
src += `?mode=${mode.value}&id=${rowId.value}`
}
return src
})
const handleClose = () => {
visible.value = false
window.removeEventListener('message', handleMessage)
}
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'purchasingrequisition:submitSuccess') {
handleClose()
emit('refresh')
} else if (event.data?.type === 'purchasingrequisition:close') {
handleClose()
}
}
const openDialog = (openMode: 'add' | 'edit' | 'view', row?: any) => {
mode.value = openMode
rowId.value = row?.id ?? ''
visible.value = true
window.addEventListener('message', handleMessage)
}
watch(visible, (val) => {
if (!val) {
window.removeEventListener('message', handleMessage)
}
})
defineExpose({
openDialog,
})
</script>
<style scoped lang="scss">
.form-iframe-content {
width: 100%;
height: 70vh;
min-height: 500px;
max-height: calc(100vh - 200px);
position: relative;
overflow: hidden;
.form-iframe {
width: 100%;
height: 100%;
min-height: 500px;
border: none;
display: block;
}
}
</style>
<style>
.form-iframe-dialog.el-dialog {
display: flex;
flex-direction: column;
max-height: 90vh;
margin-top: 5vh !important;
}
.form-iframe-dialog .el-dialog__body {
padding: 20px;
overflow: hidden;
flex: 1;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,704 @@
<template>
<div class="implement-page">
<div class="implement-form">
<!-- 步骤一选择实施采购方式 -->
<div class="step-section">
<div class="step-header">
<span class="step-number" :class="{ completed: step1Completed && !isEditingStep1 }">1</span>
<span class="step-title">选择实施采购方式</span>
<el-tag v-if="step1Completed && !isEditingStep1" type="success" size="small">已完成</el-tag>
</div>
<div class="step-content">
<el-form-item label="实施采购方式" required>
<el-radio-group v-model="implementType" :disabled="step1Completed && !isEditingStep1">
<el-radio :label="IMPLEMENT_TYPE.SELF_ORGANIZED">自行组织采购</el-radio>
<el-radio :label="IMPLEMENT_TYPE.ENTRUST_AGENT">委托代理采购</el-radio>
</el-radio-group>
</el-form-item>
<!-- 步骤一未完成显示保存按钮 -->
<el-form-item v-if="!step1Completed">
<el-button type="primary" :loading="saveTypeSubmitting" :disabled="!implementType" @click="handleSaveImplementType">保存</el-button>
</el-form-item>
<!-- 步骤一已完成但正在编辑显示修改确认按钮 -->
<el-form-item v-else-if="isEditingStep1">
<el-button type="primary" :loading="saveTypeSubmitting" :disabled="!implementType" @click="handleReSaveImplementType">确认修改</el-button>
<el-button @click="cancelEditStep1">取消</el-button>
</el-form-item>
<!-- 步骤一已完成且未在编辑显示修改按钮 -->
<el-form-item v-else>
<el-button type="default" @click="startEditStep1">修改</el-button>
</el-form-item>
</div>
</div>
<!-- 步骤二分配代理仅委托代理采购且步骤一完成时显示 -->
<div v-if="showStep2" class="step-section">
<div class="step-header">
<span class="step-number" :class="{ completed: step2Completed }">2</span>
<span class="step-title">分配代理机构</span>
<el-tag v-if="step2Completed" type="success" size="small">已完成</el-tag>
</div>
<div class="step-content">
<el-form-item label="分配方式">
<el-radio-group v-model="agentMode">
<el-radio label="designated">指定代理</el-radio>
<el-radio label="random">随机分配</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="agentMode === 'designated'" label="选择代理">
<el-select v-model="selectedAgentId" placeholder="请选择招标代理" filterable style="width: 100%" :loading="agentListLoading">
<el-option v-for="item in agentList" :key="item.id" :label="item.agentName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item v-if="agentMode === 'random'" label="随机结果">
<div class="agent-roller">
<span v-if="rollingAgentName" class="rolling">{{ rollingAgentName }}</span>
<span v-else-if="assignedAgentName" class="assigned">已分配{{ assignedAgentName }}</span>
<span v-else class="placeholder">点击下方按钮进行随机分配</span>
</div>
</el-form-item>
<el-form-item v-if="applyRow?.agentName" label="当前代理">
<el-tag>{{ applyRow.agentName }}</el-tag>
<!-- 发送状态 -->
<el-tag v-if="applyRow?.agentSent === YES_NO.YES" type="success" style="margin-left: 8px;">已发送</el-tag>
<el-tag v-else type="info" style="margin-left: 8px;">未发送</el-tag>
</el-form-item>
<!-- 指定代理按钮未发送招标代理时可操作 -->
<el-form-item v-if="agentMode === 'designated' && canReassignAgent">
<el-button type="primary" :loading="assignAgentSubmitting" :disabled="!selectedAgentId" @click="handleAssignAgentDesignated">{{ step2Completed ? '重新指定' : '指定代理' }}</el-button>
</el-form-item>
<!-- 随机分配按钮未发送招标代理时可操作 -->
<el-form-item v-if="agentMode === 'random' && canReassignAgent">
<el-button type="primary" :loading="assignAgentSubmitting" @click="handleAssignAgentRandom">随机分配</el-button>
</el-form-item>
<!-- 发送招标代理按钮 -->
<el-form-item v-if="canSendToAgent">
<el-button type="success" :loading="sendToAgentSubmitting" @click="handleSendToAgent">发送招标代理</el-button>
</el-form-item>
<!-- 撤回招标代理按钮 -->
<el-form-item v-if="canRevokeAgent">
<el-button type="warning" :loading="revokeAgentSubmitting" @click="handleRevokeAgent">撤回</el-button>
</el-form-item>
</div>
</div>
</div>
<!-- 底部按钮编辑步骤一时隐藏 -->
<div v-if="!isEditingStep1" class="implement-footer">
<el-button @click="handleClose">取消</el-button>
<!-- 只有步骤一完成后才显示确定按钮 -->
<el-button v-if="step1Completed" type="primary" :loading="implementSubmitting" @click="handleImplementSubmit">确定</el-button>
</div>
</div>
</template>
<script setup lang="ts" name="PurchasingImplement">
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { getDeptMembers, getObj, assignAgent, sendToAgent, revokeAgent, saveImplementType } from '/@/api/purchase/purchasingrequisition'
import { getPage as getAgentPage } from '/@/api/purchase/purchaseagent'
import { useMessage } from '/@/hooks/message'
import { Session } from '/@/utils/storage'
import * as orderVue from '/@/api/order/order-key-vue'
// ==================== 常量定义(与后端枚举保持一致) ====================
/** 部门审核角色编码:仅该角色下显示采购代表相关页面和功能,流转至部门审核时需填写采购代表 */
const PURCHASE_DEPT_AUDIT_ROLE_CODE = 'PURCHASE_DEPT_AUDIT'
/** 实施采购方式(与后端 ImplementTypeEnum 一致) */
const IMPLEMENT_TYPE = {
/** 自行组织采购 */
SELF_ORGANIZED: '1',
/** 委托代理采购 */
ENTRUST_AGENT: '2',
} as const
/** 采购形式(与后端 PurchaseModeEnum 一致) */
const PURCHASE_MODE = {
/** 部门自行采购 */
DEPT_SELF: '1',
/** 学校统一采购 */
SCHOOL_UNIFIED: '2',
} as const
/** 采购途径(与后端 PurchaseChannelEnum 一致) */
const PURCHASE_CHANNEL = {
/** 自行采购 */
SELF: '1',
/** 委托采购中心采购 */
ENTRUST_CENTER: '2',
} as const
/** 是否标识(与后端 CommonConstants.YES/NO 一致) */
const YES_NO = {
NO: '0',
YES: '1',
} as const
const roleCode = computed(() => Session.getRoleCode() || '')
const isDeptAuditRole = computed(() => roleCode.value === PURCHASE_DEPT_AUDIT_ROLE_CODE)
// 与编辑界面一致:支持流程 dynamic-link 传入 currJob/currElTab申请单 ID 优先取 currJob.orderId
const props = defineProps({
currJob: { type: Object, default: null },
currElTab: { type: Object, default: null }
})
const emit = defineEmits(['handleJob'])
/** 是否被流程 handle 页面通过 dynamic-link 嵌入 */
const isFlowEmbed = computed(() => !!props.currJob)
const route = useRoute()
/** 申请单 ID数值用于 getObj 等):与 add 一致,优先流程 currJob.orderId否则 route.query.id */
const applyId = computed(() => {
const raw = applyIdRaw.value
if (raw == null || raw === '') return null
const n = Number(raw)
return Number.isNaN(n) ? null : n
})
/** 申请单 ID 原始字符串(用于 getApplyFiles 的 purchaseId与编辑页一致避免类型/精度问题) */
const applyIdRaw = computed(() => {
if (props.currJob?.orderId != null && props.currJob?.orderId !== '') {
return String(props.currJob.orderId)
}
const id = route.query.id
return id != null && id !== '' ? String(id) : ''
})
const applyRow = ref<any>(null)
const implementType = ref<string>(IMPLEMENT_TYPE.SELF_ORGANIZED)
const implementSubmitting = ref(false)
/** 步骤控制 */
const step1Completed = ref(false)
const saveTypeSubmitting = ref(false)
const isEditingStep1 = ref(false)
const originalImplementType = ref<string>('')
const representorMode = ref<'single' | 'multi'>('single')
const representorTeacherNo = ref<string>('')
const representorsMulti = ref<string[]>([])
const deptMembers = ref<any[]>([])
/** 分配代理相关 */
const agentMode = ref<'designated' | 'random'>('designated')
const selectedAgentId = ref<string>('')
const agentList = ref<any[]>([])
const agentListLoading = ref(false)
const assignAgentSubmitting = ref(false)
const rollingAgentName = ref<string>('')
const assignedAgentName = ref<string>('')
const sendToAgentSubmitting = ref(false)
const revokeAgentSubmitting = ref(false)
let rollInterval: ReturnType<typeof setInterval> | null = null
/** 是否可以分配代理:委托代理采购 且 (学校统一采购 或 部门自行采购且委托采购中心采购) 且 步骤一已完成 */
const canAssignAgent = computed(() => {
// 自行组织采购不需要分配代理
if (implementType.value !== IMPLEMENT_TYPE.ENTRUST_AGENT) return false
// 步骤一必须完成
if (!step1Completed.value) return false
const row = applyRow.value
if (!row) return false
// 学校统一采购 或 部门自行采购且委托采购中心采购
return row.purchaseMode === PURCHASE_MODE.SCHOOL_UNIFIED
|| (row.purchaseMode === PURCHASE_MODE.DEPT_SELF && row.purchaseChannel === PURCHASE_CHANNEL.ENTRUST_CENTER)
})
/** 是否显示步骤二:委托代理采购 且 步骤一已完成 且 不在编辑步骤一 */
const showStep2 = computed(() => {
return step1Completed.value && !isEditingStep1.value && implementType.value === IMPLEMENT_TYPE.ENTRUST_AGENT
})
/** 步骤二是否完成:已分配代理 */
const step2Completed = computed(() => {
return !!applyRow.value?.agentId
})
/** 是否可以发送招标代理:委托代理采购 且 已分配代理 且 未发送 */
const canSendToAgent = computed(() => {
// 自行组织采购不需要发送
if (implementType.value !== IMPLEMENT_TYPE.ENTRUST_AGENT) return false
const row = applyRow.value
if (!row) return false
// 已分配代理 且 未发送
return !!row.agentId && row.agentSent !== YES_NO.YES
})
/** 是否可以重新分配代理:未发送招标代理时可操作 */
const canReassignAgent = computed(() => {
const row = applyRow.value
if (!row) return false
// 未发送招标代理时可重新分配
return row.agentSent !== YES_NO.YES
})
/** 是否可以撤回招标代理:已发送招标代理时可撤回 */
const canRevokeAgent = computed(() => {
const row = applyRow.value
if (!row) return false
// 已分配代理 且 已发送招标代理时可撤回
return !!row.agentId && row.agentSent === YES_NO.YES
})
const loadAgentList = async () => {
agentListLoading.value = true
try {
const res = await getAgentPage({ size: 500, current: 1 })
const records = res?.data?.records ?? res?.records ?? []
agentList.value = Array.isArray(records) ? records : []
} catch (_) {
agentList.value = []
} finally {
agentListLoading.value = false
}
}
/** 随机分配代理 - 滚动动画 */
const startRollingAnimation = (finalAgentName: string) => {
if (agentList.value.length === 0) return
// 清除之前的动画
if (rollInterval) {
clearInterval(rollInterval)
rollInterval = null
}
rollingAgentName.value = ''
assignedAgentName.value = ''
let currentIndex = 0
const totalDuration = 2000 // 总动画时间 2秒
const intervalTime = 80 // 滚动间隔
rollInterval = setInterval(() => {
rollingAgentName.value = agentList.value[currentIndex].agentName
currentIndex = (currentIndex + 1) % agentList.value.length
}, intervalTime)
// 2秒后停止并显示最终结果
setTimeout(() => {
if (rollInterval) {
clearInterval(rollInterval)
rollInterval = null
}
rollingAgentName.value = ''
assignedAgentName.value = finalAgentName
}, totalDuration)
}
const handleAssignAgentRandom = async () => {
const id = applyRow.value?.id ?? applyId.value
if (!id) {
useMessage().warning('无法获取申请单ID')
return
}
assignAgentSubmitting.value = true
try {
const res = await assignAgent(Number(id), 'random')
// 后端返回分配结果后,展示滚动动画
const finalAgentName = res?.data?.agentName || res?.agentName || ''
if (finalAgentName) {
startRollingAnimation(finalAgentName)
} else {
useMessage().success('随机分配代理成功')
await loadData()
}
} catch (e: any) {
useMessage().error(e?.msg || '随机分配代理失败')
} finally {
assignAgentSubmitting.value = false
}
}
const handleAssignAgentDesignated = async () => {
const id = applyRow.value?.id ?? applyId.value
if (!id) {
useMessage().warning('无法获取申请单ID')
return
}
if (!selectedAgentId.value) {
useMessage().warning('请选择招标代理')
return
}
assignAgentSubmitting.value = true
try {
await assignAgent(Number(id), 'designated', selectedAgentId.value)
useMessage().success('指定代理成功')
await loadData()
} catch (e: any) {
useMessage().error(e?.msg || '指定代理失败')
} finally {
assignAgentSubmitting.value = false
}
}
/** 发送招标代理 */
const handleSendToAgent = async () => {
const id = applyRow.value?.id ?? applyId.value
if (!id) {
useMessage().warning('无法获取申请单ID')
return
}
sendToAgentSubmitting.value = true
try {
await sendToAgent(Number(id))
useMessage().success('已发送招标代理')
await loadData()
} catch (e: any) {
useMessage().error(e?.msg || '发送招标代理失败')
} finally {
sendToAgentSubmitting.value = false
}
}
/** 撤回招标代理 */
const handleRevokeAgent = async () => {
const id = applyRow.value?.id ?? applyId.value
if (!id) {
useMessage().warning('无法获取申请单ID')
return
}
revokeAgentSubmitting.value = true
try {
await revokeAgent(Number(id))
useMessage().success('已撤回招标代理')
await loadData()
} catch (e: any) {
useMessage().error(e?.msg || '撤回招标代理失败')
} finally {
revokeAgentSubmitting.value = false
}
}
/** 步骤一:保存实施采购方式 */
const handleSaveImplementType = async () => {
const id = applyRow.value?.id ?? applyId.value
if (!id) {
useMessage().warning('无法获取申请单ID')
return
}
if (!implementType.value) {
useMessage().warning('请选择实施采购方式')
return
}
saveTypeSubmitting.value = true
try {
await saveImplementType(Number(id), implementType.value)
useMessage().success('保存成功')
step1Completed.value = true
originalImplementType.value = implementType.value
// 流程嵌入场景:通知流程 Tab 已保存
if (isFlowEmbed.value && props.currJob && props.currElTab?.id) {
orderVue.currElTabIsSave(props.currJob, props.currElTab.id, true, emit)
}
// 如果是委托代理采购,加载代理列表
if (implementType.value === IMPLEMENT_TYPE.ENTRUST_AGENT) {
await loadAgentList()
}
} catch (e: any) {
useMessage().error(e?.msg || '保存失败')
} finally {
saveTypeSubmitting.value = false
}
}
/** 开始编辑步骤一 */
const startEditStep1 = () => {
originalImplementType.value = implementType.value
isEditingStep1.value = true
}
/** 取消编辑步骤一 */
const cancelEditStep1 = () => {
implementType.value = originalImplementType.value
isEditingStep1.value = false
}
/** 重新保存实施采购方式(修改后确认) */
const handleReSaveImplementType = async () => {
const id = applyRow.value?.id ?? applyId.value
if (!id) {
useMessage().warning('无法获取申请单ID')
return
}
if (!implementType.value) {
useMessage().warning('请选择实施采购方式')
return
}
saveTypeSubmitting.value = true
try {
await saveImplementType(Number(id), implementType.value)
useMessage().success('修改成功')
isEditingStep1.value = false
originalImplementType.value = implementType.value
// 如果从委托代理采购改为自行组织采购,清空代理相关状态
if (implementType.value === IMPLEMENT_TYPE.SELF_ORGANIZED) {
assignedAgentName.value = ''
selectedAgentId.value = ''
}
// 如果改为委托代理采购,加载代理列表
if (implementType.value === IMPLEMENT_TYPE.ENTRUST_AGENT) {
await loadAgentList()
}
// 流程嵌入场景:通知流程 Tab 已保存
if (isFlowEmbed.value && props.currJob && props.currElTab?.id) {
orderVue.currElTabIsSave(props.currJob, props.currElTab.id, true, emit)
}
} catch (e: any) {
useMessage().error(e?.msg || '修改失败')
} finally {
saveTypeSubmitting.value = false
}
}
const isInIframe = () => typeof window !== 'undefined' && window.self !== window.top
const postMessage = (type: string, payload?: any) => {
if (typeof window !== 'undefined' && window.parent) {
window.parent.postMessage({ type, ...payload }, '*')
}
}
const loadData = async () => {
const id = applyId.value
if (!id) {
useMessage().warning('缺少申请单ID')
return
}
const needDeptMembers = isDeptAuditRole.value
try {
const requests: [ReturnType<typeof getObj>, ReturnType<typeof getDeptMembers>?] = [getObj(id)]
if (needDeptMembers) requests.push(getDeptMembers())
const results = await Promise.all(requests)
const detailRes = results[0]
const membersRes = needDeptMembers ? results[1] : null
applyRow.value = detailRes?.data ? { ...detailRes.data, id: detailRes.data.id ?? id } : { id }
const row = applyRow.value
if (row?.implementType) {
implementType.value = row.implementType
originalImplementType.value = row.implementType
// 已有 implementType 说明步骤一已完成
step1Completed.value = true
}
// 回显需求部门初审-采购代表人方式与人员
if (row?.representorTeacherNo) {
representorMode.value = 'single'
representorTeacherNo.value = row.representorTeacherNo ?? ''
representorsMulti.value = []
} else if (row?.representors) {
representorMode.value = 'multi'
representorTeacherNo.value = ''
const parts = typeof row.representors === 'string' ? row.representors.split(',') : []
representorsMulti.value = parts.map((s: string) => s.trim()).filter(Boolean)
} else {
representorTeacherNo.value = ''
representorsMulti.value = []
}
deptMembers.value = needDeptMembers && membersRes?.data ? membersRes.data : []
// 加载代理列表并回显已分配代理(委托代理采购时)
if (step1Completed.value && implementType.value === IMPLEMENT_TYPE.ENTRUST_AGENT) {
// 先判断是否可以分配代理(学校统一采购 或 部门自行采购且委托采购中心采购)
const canLoadAgent = row.purchaseMode === PURCHASE_MODE.SCHOOL_UNIFIED
|| (row.purchaseMode === PURCHASE_MODE.DEPT_SELF && row.purchaseChannel === PURCHASE_CHANNEL.ENTRUST_CENTER)
if (canLoadAgent) {
await loadAgentList()
// 回显已分配代理
if (row?.agentId) {
selectedAgentId.value = row.agentId
assignedAgentName.value = row.agentName || ''
}
}
}
} catch (_) {
applyRow.value = { id }
deptMembers.value = []
}
}
const handleClose = () => {
postMessage('purchasingimplement:close')
if (!isInIframe()) {
window.history.back()
}
}
const handleImplementSubmit = async () => {
const row = applyRow.value
if (!row?.id && !applyId.value) return
const id = row?.id ?? applyId.value
if (!id) return
// 步骤一未完成时,先保存步骤一
if (!step1Completed.value) {
if (!implementType.value) {
useMessage().warning('请选择实施采购方式')
return
}
// 自动保存步骤一
saveTypeSubmitting.value = true
try {
await saveImplementType(Number(id), implementType.value)
step1Completed.value = true
} catch (e: any) {
useMessage().error(e?.msg || '保存实施采购方式失败')
return
} finally {
saveTypeSubmitting.value = false
}
}
implementSubmitting.value = true
try {
useMessage().success('实施采购已保存')
postMessage('purchasingimplement:saved')
// 流程嵌入场景:通知流程当前 Tab 已保存
if (isFlowEmbed.value && props.currJob && props.currElTab?.id) {
orderVue.currElTabIsSave(props.currJob, props.currElTab.id, true, emit)
}
} catch (err: any) {
useMessage().error(err?.msg || '实施采购失败')
} finally {
implementSubmitting.value = false
}
}
/** 流程嵌入时供 handle.vue 调用的"保存"回调:与页面按钮保存逻辑保持一致 */
async function flowSubmitForm() {
await handleImplementSubmit()
}
// 流程切换工单时重新加载数据(与 add 编辑页一致)
watch(
() => props.currJob?.orderId ?? props.currJob?.id,
(newVal, oldVal) => {
if (newVal !== oldVal && applyId.value) {
loadData()
}
}
)
onMounted(async () => {
await loadData()
if (isInIframe()) {
document.documentElement.classList.add('iframe-mode')
document.body.classList.add('iframe-mode')
}
// 流程嵌入:注册 tab 保存回调,供审批页调用(与采购申请编辑页保持一致)
if (isFlowEmbed.value && props.currJob && props.currElTab?.id) {
orderVue.currElTabIsExist(props.currJob, props.currElTab.id)
await orderVue.currElTabIsView({}, props.currJob, props.currElTab.id, flowSubmitForm)
}
})
onUnmounted(() => {
// 清理滚动动画定时器
if (rollInterval) {
clearInterval(rollInterval)
rollInterval = null
}
})
</script>
<style scoped lang="scss">
.implement-page {
padding: 20px;
min-height: 100%;
display: flex;
flex-direction: column;
}
.implement-form {
flex: 1;
.mb-2 {
margin-bottom: 8px;
}
}
.implement-form-tip {
margin-top: 12px;
padding: 8px 0;
}
.implement-footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.step-section {
margin-bottom: 24px;
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
}
.step-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.step-number {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--el-color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
&.completed {
background: var(--el-color-success);
}
}
.step-title {
font-weight: 500;
font-size: 16px;
color: var(--el-text-color-primary);
}
.step-content {
padding-left: 40px;
}
.agent-roller {
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: 4px;
min-height: 40px;
display: flex;
align-items: center;
.rolling {
font-size: 16px;
font-weight: 500;
color: var(--el-color-primary);
animation: blink 0.1s infinite;
}
.assigned {
font-size: 16px;
font-weight: 600;
color: var(--el-color-success);
}
.placeholder {
color: var(--el-text-color-placeholder);
}
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<el-dialog
v-model="visible"
title="实施采购"
width="50%"
:close-on-click-modal="false"
destroy-on-close
class="implement-iframe-dialog"
@close="handleClose"
>
<div class="implement-iframe-content">
<iframe
ref="iframeRef"
:src="iframeSrc"
frameborder="0"
class="implement-iframe"
/>
</div>
</el-dialog>
</template>
<script setup lang="ts" name="ImplementForm">
import { ref, computed, watch } from 'vue'
const emit = defineEmits<{
(e: 'refresh'): void
}>()
const visible = ref(false)
const iframeRef = ref<HTMLIFrameElement>()
const applyId = ref<string | number>('')
const iframeSrc = computed(() => {
const baseUrl = window.location.origin + window.location.pathname
return `${baseUrl}#/purchase/purchasingrequisition/implement?id=${applyId.value}`
})
const handleClose = () => {
visible.value = false
window.removeEventListener('message', handleMessage)
}
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'purchasingimplement:submitSuccess') {
handleClose()
emit('refresh')
} else if (event.data?.type === 'purchasingimplement:close') {
handleClose()
} else if (event.data?.type === 'purchasingimplement:saved') {
emit('refresh')
}
}
const openDialog = (row: { id: string | number }) => {
applyId.value = row?.id ?? ''
visible.value = true
window.addEventListener('message', handleMessage)
}
watch(visible, (val) => {
if (!val) {
window.removeEventListener('message', handleMessage)
}
})
defineExpose({
openDialog,
})
</script>
<style scoped lang="scss">
.implement-iframe-content {
width: 100%;
height: 65vh;
min-height: 480px;
max-height: calc(100vh - 180px);
position: relative;
overflow: hidden;
.implement-iframe {
width: 100%;
height: 100%;
min-height: 480px;
border: none;
display: block;
}
}
</style>
<style>
.implement-iframe-dialog .el-dialog__body {
padding: 16px;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,888 @@
<template>
<div class="modern-page-container">
<div class="page-wrapper">
<!-- 搜索表单卡片 -->
<el-card v-show="showSearch" class="search-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="title-icon"><Search /></el-icon>
筛选条件
</span>
</div>
</template>
<el-form :model="state.queryForm" ref="searchFormRef" :inline="true" @keyup.enter="getDataList" class="search-form">
<el-form-item label="采购编号" prop="purchaseNo">
<el-input
v-model="state.queryForm.purchaseNo"
placeholder="请输入采购编号"
clearable
style="width: 200px" />
</el-form-item>
<el-form-item label="采购项目名称" prop="projectName">
<el-input
v-model="state.queryForm.projectName"
placeholder="请输入采购项目名称"
clearable
style="width: 200px" />
</el-form-item>
<el-form-item label="项目类别" prop="projectType">
<el-select
v-model="state.queryForm.projectType"
placeholder="请选择项目类别"
clearable
style="width: 200px">
<el-option label="货物" value="A" />
<el-option label="工程" value="B" />
<el-option label="服务" value="C" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select
v-model="state.queryForm.status"
placeholder="请选择状态"
clearable
style="width: 200px">
<el-option label="暂存" value="-1" />
<el-option label="运行中" value="0" />
<el-option label="完成" value="1" />
<el-option label="作废" value="2" />
</el-select>
</el-form-item>
<el-form-item label="是否集采" prop="isCentralized">
<el-select
v-model="state.queryForm.isCentralized"
placeholder="请选择是否集采"
clearable
style="width: 200px">
<el-option label="否" value="0" />
<el-option label="是" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="getDataList">查询</el-button>
<el-button icon="Refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 内容卡片 -->
<el-card class="content-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="title-icon"><Document /></el-icon>
采购申请管理
</span>
<div class="header-actions">
<el-button
icon="Files"
link
type="primary"
>
采购申请汇总
</el-button>
<el-button
icon="FolderAdd"
type="primary"
@click="handleAdd">
新增
</el-button>
<right-toolbar v-model:showSearch="showSearch" class="ml10" @queryTable="getDataList" />
</div>
</div>
</template>
<!-- 表格 -->
<el-table
ref="tableRef"
:data="state.dataList"
v-loading="state.loading"
stripe
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
class="modern-table">
<el-table-column type="index" label="序号" width="70" align="center">
<template #header>
<el-icon><List /></el-icon>
</template>
</el-table-column>
<el-table-column prop="purchaseNo" label="申请单编号" min-width="140" show-overflow-tooltip>
<template #header>
<el-icon><DocumentCopy /></el-icon>
<span style="margin-left: 4px">申请单编号</span>
</template>
</el-table-column>
<el-table-column prop="projectName" label="采购项目名称" min-width="200" show-overflow-tooltip>
<template #header>
<el-icon><Document /></el-icon>
<span style="margin-left: 4px">采购项目名称</span>
</template>
</el-table-column>
<el-table-column prop="applyDate" label="填报日期" width="120" align="center" show-overflow-tooltip>
<template #header>
<el-icon><Calendar /></el-icon>
<span style="margin-left: 4px">填报日期</span>
</template>
</el-table-column>
<el-table-column prop="deptName" label="需求部门" min-width="150" show-overflow-tooltip>
<template #header>
<el-icon><OfficeBuilding /></el-icon>
<span style="margin-left: 4px">需求部门</span>
</template>
</el-table-column>
<el-table-column prop="projectType" label="项目类别" min-width="200" align="left" show-overflow-tooltip>
<template #header>
<el-icon><Collection /></el-icon>
<span style="margin-left: 4px">项目类别</span>
</template>
<template #default="scope">
<div>
<el-tag v-if="scope.row.projectType === 'A'" type="success" style="margin-right: 8px;">货物</el-tag>
<el-tag v-else-if="scope.row.projectType === 'B'" type="warning" style="margin-right: 8px;">工程</el-tag>
<el-tag v-else-if="scope.row.projectType === 'C'" type="info" style="margin-right: 8px;">服务</el-tag>
<span v-if="scope.row.categoryName" style="color: #606266; font-size: 12px;">
{{ scope.row.categoryName }}
</span>
<span v-else-if="scope.row.categoryCode" style="color: #909399; font-size: 12px;">
{{ scope.row.categoryCode }}
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="budget" label="项目预算(元)" width="130" align="right">
<template #header>
<el-icon><Money /></el-icon>
<span style="margin-left: 4px">项目预算</span>
</template>
<template #default="scope">
{{ scope.row.budget ? Number(scope.row.budget).toLocaleString() : '-' }}
</template>
</el-table-column>
<el-table-column prop="isSpecial" label="是否特殊" width="100" align="center">
<template #header>
<el-icon><Warning /></el-icon>
<span style="margin-left: 4px">是否特殊</span>
</template>
<template #default="scope">
<el-tag v-if="String(scope.row.isSpecial) === '1'" type="warning">紧急</el-tag>
<el-tag v-else-if="String(scope.row.isSpecial) === '2'" type="danger">单一</el-tag>
<el-tag v-else-if="String(scope.row.isSpecial) === '3'" type="info">进口</el-tag>
<el-tag v-else-if="String(scope.row.isSpecial) === '0'" type="info"></el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="isCentralized" label="是否集采" width="100" align="center">
<template #header>
<el-icon><CircleCheck /></el-icon>
<span style="margin-left: 4px">是否集采</span>
</template>
<template #default="scope">
<el-tag v-if="String(scope.row.isCentralized) === '1'" type="success"></el-tag>
<el-tag v-else-if="String(scope.row.isCentralized) === '0'" type="info"></el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="status" label="审核状态" width="100" align="center">
<template #header>
<el-icon><InfoFilled /></el-icon>
<span style="margin-left: 4px">审核状态</span>
</template>
<template #default="scope">
<el-tooltip v-if="scope.row.flowInstId" content="点击查看审批过程" placement="top">
<el-tag
v-if="scope.row.status === '-2'"
type="info"
class="status-tag-clickable"
@click="handleShowFlowComment(scope.row)">撤回</el-tag>
<el-tag
v-else-if="scope.row.status === '-1'"
type="warning"
class="status-tag-clickable"
@click="handleShowFlowComment(scope.row)">暂存</el-tag>
<el-tag
v-else-if="scope.row.status === '0'"
type="primary"
class="status-tag-clickable"
@click="handleShowFlowComment(scope.row)">运行中</el-tag>
<el-tag
v-else-if="scope.row.status === '1'"
type="success"
class="status-tag-clickable"
@click="handleShowFlowComment(scope.row)">完成</el-tag>
<el-tag
v-else-if="scope.row.status === '2'"
type="danger"
class="status-tag-clickable"
@click="handleShowFlowComment(scope.row)">作废</el-tag>
<el-tag
v-else-if="scope.row.status === '3'"
type="info"
class="status-tag-clickable"
@click="handleShowFlowComment(scope.row)">终止</el-tag>
<span v-else>-</span>
</el-tooltip>
<template v-else>
<el-tag v-if="scope.row.status === '-2'" type="info">撤回</el-tag>
<el-tag v-else-if="scope.row.status === '-1'" type="warning">暂存</el-tag>
<el-tag v-else-if="scope.row.status === '0'" type="primary">运行中</el-tag>
<el-tag v-else-if="scope.row.status === '1'" type="success">完成</el-tag>
<el-tag v-else-if="scope.row.status === '2'" type="danger">作废</el-tag>
<el-tag v-else-if="scope.row.status === '3'" type="info">终止</el-tag>
<span v-else>-</span>
</template>
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" width="150">
<template #default="scope">
<div class="op-cell">
<el-button
type="primary"
link
icon="View"
@click="handleView(scope.row)">
查看
</el-button>
<ActionDropdown
:items="getActionMenuItems(scope.row)"
@command="(command) => handleMoreCommand(command, scope.row)"
/>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination
v-if="state.pagination && state.pagination.total && state.pagination.total > 0"
:total="state.pagination.total"
:current="state.pagination.current"
:size="state.pagination.size"
@sizeChange="sizeChangeHandle"
@currentChange="currentChangeHandle"
/>
</el-card>
</div>
<!-- 新增/编辑/查看统一使用 form.vue 弹窗iframe 引入 add.vue -->
<FormDialog
ref="formDialogRef"
:dict-data="dictData"
@refresh="getDataList" />
<!-- 履约验收弹窗 -->
<PurchasingAcceptModal ref="acceptModalRef" @refresh="getDataList" />
<!-- 查看审批过程申请单审批 / 文件审批 -->
<el-dialog
v-model="showFlowComment"
v-if="showFlowComment"
:title="currFlowCommentType === 'file' ? '查看文件审批过程' : '查看审批过程'"
top="20px"
width="90%"
append-to-body
destroy-on-close
@close="currFlowJob = null; currFlowCommentType = 'apply'">
<FlowCommentTimeline v-if="currFlowJob" :key="String(currFlowJob.flowInstId) + currFlowCommentType" :curr-job="currFlowJob" />
</el-dialog>
<!-- 实施采购iframe 嵌入 implement.vue供列表与流程页面使用 -->
<ImplementForm ref="implementFormRef" @refresh="getDataList" />
<!-- 采购文件审核弹窗 -->
<DocAuditDialog ref="docAuditDialogRef" @refresh="getDataList" />
<!-- 采购代表弹窗 -->
<el-dialog
v-model="representorDialogVisible"
title="设置采购代表"
width="500px"
destroy-on-close
>
<el-form label-width="100px">
<el-form-item label="选择方式">
<el-radio-group v-model="representorForm.mode">
<el-radio label="single">指定采购代表人</el-radio>
<el-radio label="multi">部门多人系统抽取</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="representorForm.mode === 'single'" label="采购代表人">
<el-select
v-model="representorForm.teacherNo"
placeholder="请选择"
clearable
filterable
style="width: 100%"
>
<el-option v-for="m in representorDeptMembers" :key="m.userId || m.teacherNo || m.id" :label="m.realName || m.name || m.teacherNo" :value="m.userId || m.teacherNo || m.id" />
</el-select>
</el-form-item>
<el-form-item v-else label="部门多人">
<el-select
v-model="representorForm.multiIds"
multiple
placeholder="请选择多人,系统将自动抽取一人"
clearable
filterable
style="width: 100%"
>
<el-option v-for="m in representorDeptMembers" :key="m.userId || m.teacherNo || m.id" :label="m.realName || m.name || m.teacherNo" :value="m.userId || m.teacherNo || m.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="representorDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="representorSubmitting" @click="handleSaveRepresentor">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="PurchasingRequisition">
import { ref, reactive, defineAsyncComponent, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { BasicTableProps, useTable } from "/@/hooks/table";
import { getPage, delObj, submitObj, getArchiveDownloadUrl, getApplyTemplateDownloadUrl, getFileApplyTemplateDownloadUrl, getDeptMembers, saveRepresentor } from "/@/api/purchase/purchasingrequisition";
import { useMessage, useMessageBox } from "/@/hooks/message";
import { useAuth } from '/@/hooks/auth';
import { getDicts } from '/@/api/admin/dict';
import { getTree } from '/@/api/purchase/purchasingcategory';
import { List, Document, DocumentCopy, Search, Money, CircleCheck, InfoFilled, Calendar, OfficeBuilding, Warning, DocumentChecked, Edit, Delete, Upload, FolderOpened, Download, User } from '@element-plus/icons-vue'
import other from '/@/utils/other'
import { Session } from '/@/utils/storage'
// 角色常量
const PURCHASE_DEPT_AUDIT_ROLE_CODE = 'PURCHASE_DEPT_AUDIT'
const roleCode = computed(() => Session.getRoleCode() || '')
const isDeptAuditRole = computed(() => roleCode.value === PURCHASE_DEPT_AUDIT_ROLE_CODE)
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
const ImplementForm = defineAsyncComponent(() => import('./implementForm.vue'));
const ActionDropdown = defineAsyncComponent(() => import('/@/components/tools/action-dropdown.vue'));
const PurchasingAcceptModal = defineAsyncComponent(() => import('./accept/PurchasingAcceptModal.vue'));
const FlowCommentTimeline = defineAsyncComponent(() => import('/@/views/jsonflow/comment/timeline.vue'));
const DocAuditDialog = defineAsyncComponent(() => import('./docAudit/DocAuditDialog.vue'));
// 字典数据和品目树数据
const dictData = ref({
fundSourceList: [] as any[],
isCentralizedList: [] as any[],
isSpecialList: [] as any[],
purchaseTypeDeptList: [] as any[],
purchaseModeSchoolList: [] as any[],
purchaseTypeUnionList: [] as any[],
categoryTreeData: [] as any[],
});
// 定义变量内容
const router = useRouter()
const tableRef = ref()
const formDialogRef = ref()
const acceptModalRef = ref()
const searchFormRef = ref()
const showSearch = ref(true)
const docAuditDialogRef = ref()
const { hasAuth } = useAuth()
/** 审批过程弹窗:是否显示、当前行对应的流程 job供 Comment 组件用)、类型(申请单/文件) */
const showFlowComment = ref(false)
const currFlowJob = ref<{ id?: number; flowInstId?: number } | null>(null)
const currFlowCommentType = ref<'apply' | 'file'>('apply')
const implementFormRef = ref()
/** 采购代表弹窗 */
const representorDialogVisible = ref(false)
const representorCurrentRow = ref<any>(null)
const representorForm = reactive({ mode: 'single' as 'single' | 'multi', teacherNo: '', multiIds: [] as string[] })
const representorDeptMembers = ref<any[]>([])
const representorSubmitting = ref(false)
const openRepresentorDialog = async (row: any) => {
representorCurrentRow.value = row
representorForm.mode = 'single'
representorForm.teacherNo = ''
representorForm.multiIds = []
representorDialogVisible.value = true
try {
const res = await getDeptMembers()
representorDeptMembers.value = res?.data || []
} catch (_) {
representorDeptMembers.value = []
}
}
const handleSaveRepresentor = async () => {
const row = representorCurrentRow.value
const id = row?.id ?? row?.purchaseId
if (id == null || id === '') {
useMessage().warning('无法获取申请单ID')
return
}
if (representorForm.mode === 'single' && !representorForm.teacherNo) {
useMessage().warning('请选择采购代表人')
return
}
if (representorForm.mode === 'multi' && !representorForm.multiIds.length) {
useMessage().warning('请选择部门多人')
return
}
representorSubmitting.value = true
try {
const teacherNo = representorForm.mode === 'single' ? representorForm.teacherNo : undefined
const multiIds = representorForm.mode === 'multi' ? representorForm.multiIds.join(',') : undefined
await saveRepresentor(Number(id), teacherNo, multiIds)
useMessage().success('保存采购代表成功')
representorDialogVisible.value = false
getDataList()
} catch (e: any) {
useMessage().error(e?.msg || '保存采购代表失败')
} finally {
representorSubmitting.value = false
}
}
/**
* 定义响应式表格数据
*/
const state: BasicTableProps = reactive<BasicTableProps>({
pageList: getPage,
queryForm: {
purchaseNo: '',
projectName: '',
projectType: '',
status: '',
isCentralized: '',
},
createdIsNeed: true
});
/**
* 使用 useTable 定义表格相关操作
*/
const { getDataList, tableStyle, sizeChangeHandle, currentChangeHandle } = useTable(state);
/**
* 重置搜索表单
*/
const handleReset = () => {
searchFormRef.value?.resetFields();
getDataList();
};
/**
* 新增采购申请 - 统一通过 form.vue 弹窗iframe 引入 add.vue
*/
const handleAdd = () => {
formDialogRef.value?.openDialog('add')
};
/**
/**
* 点击审核状态:若有流程实例则打开「查看审批过程」弹窗(参考 hi-job.vue
* @param row - 当前行数据(需含 flowInstId
*/
/** 点击审核状态:打开申请单审批过程 */
const handleShowFlowComment = (row: any) => {
if (!row?.flowInstId) {
useMessage().info('暂存状态无审批过程');
return;
}
currFlowCommentType.value = 'apply';
currFlowJob.value = { id: row.id, flowInstId: row.flowInstId };
showFlowComment.value = true;
};
/** 点击文件审批状态:打开文件审批过程 */
const handleShowFileFlowComment = (row: any) => {
if (!row?.fileFlowInstId) {
useMessage().info('未发起文件审批流程');
return;
}
currFlowCommentType.value = 'file';
const flowInstId = typeof row.fileFlowInstId === 'string' ? parseInt(row.fileFlowInstId, 10) : row.fileFlowInstId;
currFlowJob.value = { id: row.id, flowInstId: Number.isNaN(flowInstId) ? row.fileFlowInstId : flowInstId };
showFlowComment.value = true;
};
/**
* 打开查看对话框
* @param row - 当前行数据
*/
const handleView = (row: any) => {
formDialogRef.value?.openDialog('view', row);
};
/**
* 打开编辑对话框
* @param row - 当前行数据
*/
const handleEdit = (row: any) => {
formDialogRef.value?.openDialog('edit', row);
};
/**
* 履约验收
* @param row - 当前行数据
*/
const handleAccept = (row: any) => {
acceptModalRef.value?.open(row);
};
/** 打开实施采购(仅暂存状态可点;通过 iframe 嵌入 implement.vue */
const handleImplement = (row: any) => {
implementFormRef.value?.openDialog(row);
};
/** 打开采购文件审核 */
const handleDocAudit = (row: any) => {
docAuditDialogRef.value?.open(row);
};
/**
* 删除当前行
* @param row - 当前行数据
*/
const handleDelete = async (row: any) => {
try {
await useMessageBox().confirm('确定要删除该记录吗?');
} catch {
return;
}
try {
await delObj({id:row.id});
useMessage().success('删除成功');
getDataList();
} catch (err: any) {
useMessage().error(err.msg || '删除失败');
}
};
/** 暂存状态下提交采购申请(启动流程) */
const handleSubmit = async (row: any) => {
try {
await useMessageBox().confirm('确定要提交该采购申请并启动流程吗?');
} catch {
return;
}
try {
await submitObj({ id: row.id });
useMessage().success('提交成功');
getDataList();
} catch (err: any) {
useMessage().error(err?.msg || '提交失败');
}
};
/** 操作栏「更多」菜单项配置 */
const getActionMenuItems = (row: any) => {
const isTemp = row?.status === '-1';
const isCompleted = row?.status === '1';
const items = [
{
command: 'edit',
label: '编辑',
icon: Edit,
visible: () => isTemp,
},
{
command: 'submit',
label: '提交',
icon: Upload,
visible: () => isTemp,
},
{
command: 'delete',
label: '删除',
icon: Delete,
visible: () => isTemp,
},
{
command: 'accept',
label: '履约验收',
icon: DocumentChecked,
visible: () => hasAuth('purchase_accept'),
},
{
command: 'implement',
label: '实施采购',
icon: Upload,
visible: () => isCompleted && hasAuth('purchase_implement'),
},
{
command: 'archive',
label: '文件归档',
icon: FolderOpened,
visible: () => isCompleted && hasAuth('purchase_archive'),
},
{
command: 'downloadApply',
label: '下载审批表',
icon: Download,
visible: () => isCompleted,
},
{
command: 'representor',
label: '采购代表',
icon: User,
visible: () => isDeptAuditRole.value,
},
// {
// command: 'downloadFileApply',
// label: '下载文件审批表',
// icon: Download,
// visible: () => true,
// },
// {
// command: 'docAudit',
// label: '采购文件审核',
// icon: DocumentChecked,
// visible: () => row?.implementType === '2' && row?.agentId,
// },
];
// 过滤出有权限且可见的菜单项
return items.filter(item => {
if (item.visible === undefined) return true;
if (typeof item.visible === 'boolean') return item.visible;
if (typeof item.visible === 'function') return item.visible();
return false;
});
};
/** 处理更多操作下拉菜单命令 */
const handleMoreCommand = (command: string, row: any) => {
switch (command) {
case 'edit':
handleEdit(row);
break;
case 'submit':
handleSubmit(row);
break;
case 'delete':
handleDelete(row);
break;
case 'accept':
handleAccept(row);
break;
case 'implement':
handleImplement(row);
break;
case 'archive':
handleArchive(row);
break;
case 'downloadApply':
handleDownloadApply(row);
break;
case 'downloadFileApply':
handleDownloadFileApply(row);
break;
case 'docAudit':
handleDocAudit(row);
break;
case 'representor':
openRepresentorDialog(row);
break;
}
};
/** 下载审批表 */
const handleDownloadApply = (row: any) => {
const id = row?.id ?? row?.purchaseId;
if (id == null || id === '') {
useMessage().warning('无法获取申请单ID');
return;
}
const url = getApplyTemplateDownloadUrl(id);
const fileName = `审批表_${row?.purchaseNo || id}.docx`;
other.downBlobFile(url, {}, fileName);
};
/** 下载文件审批表 */
const handleDownloadFileApply = (row: any) => {
const id = row?.id ?? row?.purchaseId;
if (id == null || id === '') {
useMessage().warning('无法获取申请单ID');
return;
}
const url = getFileApplyTemplateDownloadUrl(id);
const fileName = `文件审批表_${row?.purchaseNo || id}.docx`;
other.downBlobFile(url, {}, fileName);
};
/** 文件归档:按文件类型打包下载该申请单下所有附件 */
const handleArchive = (row: any) => {
const id = row?.id ?? row?.purchaseId;
if (id == null || id === '') {
useMessage().warning('无法获取申请单ID');
return;
}
const url = getArchiveDownloadUrl(id);
const fileName = `归档_${row?.purchaseNo || id}.zip`;
other.downBlobFile(url, {}, fileName);
};
// 获取字典数据和品目树数据
const loadDictData = async () => {
try {
const [
fundSourceRes,
isCentralizedRes,
isSpecialRes,
purchaseTypeDeptRes,
purchaseModeSchoolRes,
purchaseTypeUnionRes,
categoryTreeRes
] = await Promise.all([
getDicts('PURCHASE_FUND_SOURCE'),
getDicts('PURCHASE_IS_CEN'),
getDicts('PURCHASE_IS_SPEC'),
getDicts('PURCHASE_TYPE_DEPT'),
getDicts('PURCHASE_MODE_SCHOOL'),
getDicts('PURCHASE_TYPE_UNION'),
getTree()
]);
// 处理资金来源字典
if (fundSourceRes.data && Array.isArray(fundSourceRes.data)) {
dictData.value.fundSourceList = fundSourceRes.data.map((item: any) => ({
label: item.label || item.dictLabel || item.name,
value: item.value || item.dictValue || item.code
}));
} else {
dictData.value.fundSourceList = [
{ label: '切块经费', value: '0' },
{ label: '设备购置费', value: '1' },
{ label: '专项经费', value: '2' },
{ label: '代办费', value: '3' },
{ label: '培训经费', value: '4' },
{ label: '日常公用经费', value: '5' },
{ label: '技能大赛经费', value: '6' },
{ label: '基本建设资金', value: '7' },
{ label: '暂存款', value: '8' },
{ label: '会议费', value: '9' },
];
}
// 处理是否集采字典
if (isCentralizedRes.data && Array.isArray(isCentralizedRes.data)) {
dictData.value.isCentralizedList = isCentralizedRes.data.map((item: any) => ({
label: item.label || item.dictLabel || item.name,
value: item.value || item.dictValue || item.code
}));
} else {
dictData.value.isCentralizedList = [
{ label: '否', value: '0' },
{ label: '政府集中采购', value: '1' },
{ label: '学校集中采购', value: '2' }
];
}
// 处理是否特殊情况字典
if (isSpecialRes.data && Array.isArray(isSpecialRes.data)) {
dictData.value.isSpecialList = isSpecialRes.data.map((item: any) => ({
label: item.label || item.dictLabel || item.name,
value: item.value || item.dictValue || item.code
}));
} else {
dictData.value.isSpecialList = [
{ label: '否', value: '0' },
{ label: '紧急', value: '1' },
{ label: '单一', value: '2' },
{ label: '进口', value: '3' }
];
}
// 处理部门采购方式字典
if (purchaseTypeDeptRes.data && Array.isArray(purchaseTypeDeptRes.data)) {
dictData.value.purchaseTypeDeptList = purchaseTypeDeptRes.data.map((item: any) => ({
label: item.label || item.dictLabel || item.name,
value: item.value || item.dictValue || item.code
}));
}
// 处理学校采购形式字典
if (purchaseModeSchoolRes.data && Array.isArray(purchaseModeSchoolRes.data)) {
dictData.value.purchaseModeSchoolList = purchaseModeSchoolRes.data.map((item: any) => ({
label: item.label || item.dictLabel || item.name,
value: item.value || item.dictValue || item.code
}));
} else {
dictData.value.purchaseModeSchoolList = [
{ label: '政府采购', value: '1' },
{ label: '学校自主采购', value: '2' }
];
}
// 处理学校统一采购方式字典
if (purchaseTypeUnionRes.data && Array.isArray(purchaseTypeUnionRes.data)) {
dictData.value.purchaseTypeUnionList = purchaseTypeUnionRes.data.map((item: any) => ({
label: item.label || item.dictLabel || item.name,
value: item.value || item.dictValue || item.code
}));
}
// 处理品目树数据
if (categoryTreeRes.data && Array.isArray(categoryTreeRes.data)) {
dictData.value.categoryTreeData = categoryTreeRes.data;
} else {
dictData.value.categoryTreeData = [];
}
} catch (err) {
console.error('加载字典数据失败', err);
// 设置默认值
dictData.value.fundSourceList = [
{ label: '切块经费', value: '0' },
{ label: '设备购置费', value: '1' },
{ label: '专项经费', value: '2' },
{ label: '代办费', value: '3' },
{ label: '培训经费', value: '4' },
{ label: '日常公用经费', value: '5' },
{ label: '技能大赛经费', value: '6' },
{ label: '基本建设资金', value: '7' },
{ label: '暂存款', value: '8' },
{ label: '会议费', value: '9' },
];
dictData.value.isCentralizedList = [
{ label: '否', value: '0' },
{ label: '政府集中采购', value: '1' },
{ label: '学校集中采购', value: '2' }
];
dictData.value.isSpecialList = [
{ label: '否', value: '0' },
{ label: '紧急', value: '1' },
{ label: '单一', value: '2' },
{ label: '进口', value: '3' }
];
dictData.value.purchaseModeSchoolList = [
{ label: '政府采购', value: '1' },
{ label: '学校自主采购', value: '2' }
];
dictData.value.categoryTreeData = [];
}
};
// 页面加载时获取字典数据和品目树数据
onMounted(() => {
loadDictData();
});
</script>
<style scoped lang="scss">
@import '/@/assets/styles/modern-page.scss';
.op-cell {
display: flex;
align-items: center;
justify-content: center;
}
.status-tag-clickable {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,271 @@
<template>
<div class="modern-page-container">
<div class="page-wrapper">
<el-card class="content-card" shadow="never">
<template #header>
<div class="card-header">
<div class="header-actions">
<el-button
type="primary"
icon="FolderAdd"
@click="openUploadDialog()"
v-auth="'purchase_template_add'">
新增模板
</el-button>
<el-alert type="info" :closable="false" class="mb3 mt-3" show-icon>
此处模版中的模版编码对应用户端下载模版匹配请勿随意修改或删除正常情况下如有发生模版变化重新上传即可如有新增模版请联系管理员进行处理
</el-alert>
</div>
</div>
</template>
<el-table :data="tableData" v-loading="loading" stripe class="modern-table">
<el-table-column type="index" label="序号" width="70" align="center">
<template #header>
<el-icon><List /></el-icon>
</template>
</el-table-column>
<el-table-column prop="templateTitle" label="模板类型名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="templateType" label="模板类型编码" min-width="180" show-overflow-tooltip />
<el-table-column prop="templateName" label="模板名称" min-width="220" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" width="180" show-overflow-tooltip />
<el-table-column label="操作" width="260" align="center" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
icon="Download"
@click="handleDownload(row)"
v-auth="'purchase_template_view'">
下载
</el-button>
<el-button
type="primary"
link
icon="UploadFilled"
@click="openUploadDialog(row)"
v-auth="'purchase_template_add'">
重新上传
</el-button>
<el-button
type="primary"
link
icon="Edit"
@click="openEditDialog(row)"
v-auth="'purchase_template_add'">
编辑
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="uploadDialogVisible" title="上传模板" width="450px" destroy-on-close>
<el-form :model="uploadForm" label-width="100px">
<el-form-item label="模板类型编码" required>
<el-input
v-model="uploadForm.templateType"
placeholder="例如: business_negotiation, inquiry"
:disabled="!!uploadForm.lockType"
/>
</el-form-item>
<el-form-item label="模板类型名称">
<el-input
v-model="uploadForm.templateTitle"
placeholder="例如: 部门采购询价模版"
/>
</el-form-item>
<el-form-item label="模板文件" required>
<el-upload
class="upload-block"
:auto-upload="false"
:limit="1"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<el-button type="primary" icon="UploadFilled">选择文件</el-button>
<template #tip>
<div class="el-upload__tip">支持 docdocx Word 模板文件上传后前端下载将使用该文件</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadDialogVisible = false"> </el-button>
<el-button type="primary" :loading="uploading" @click="handleUploadConfirm"> </el-button>
</template>
</el-dialog>
<el-dialog v-model="editDialogVisible" title="编辑模板" width="420px" destroy-on-close>
<el-form :model="editForm" label-width="100px">
<el-form-item label="模板类型编码">
<el-input v-model="editForm.templateType" disabled />
</el-form-item>
<el-form-item label="模板类型名称" required>
<el-input v-model="editForm.templateTitle" placeholder="请输入模板类型名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false"> </el-button>
<el-button type="primary" :loading="editing" @click="handleEditConfirm"> </el-button>
</template>
</el-dialog>
</div>
</div>
</template>
<script setup lang="ts" name="PurchasingTemplateManage">
import { ref, reactive, onMounted } from 'vue';
import { Document, List, UploadFilled, Download, Edit } from '@element-plus/icons-vue';
import { listTemplates, uploadTemplate, getTemplateDownloadUrl, updateTemplateTitle } from '/@/api/purchase/purchasingtemplate';
import { useMessage } from '/@/hooks/message';
const loading = ref(false);
const tableData = ref<any[]>([]);
const uploadDialogVisible = ref(false);
const uploading = ref(false);
const uploadForm = reactive<{
templateType: string;
templateTitle: string;
lockType?: boolean;
}>({
templateType: '',
templateTitle: '',
lockType: false,
});
const editDialogVisible = ref(false);
const editing = ref(false);
const editForm = reactive<{
id: number | null;
templateType: string;
templateTitle: string;
}>({
id: null,
templateType: '',
templateTitle: '',
});
const fileList = ref<any[]>([]);
const currentFile = ref<File | null>(null);
const fetchData = async () => {
loading.value = true;
try {
const res = await listTemplates();
tableData.value = (res && res.data) || [];
} catch (e) {
tableData.value = [];
} finally {
loading.value = false;
}
};
onMounted(fetchData);
const openUploadDialog = (row?: any) => {
uploadDialogVisible.value = true;
uploadForm.templateType = row?.templateType || '';
uploadForm.templateTitle = row?.templateTitle || '';
uploadForm.lockType = !!row?.templateType;
fileList.value = [];
currentFile.value = null;
};
const handleFileChange = (file: any, files: any[]) => {
fileList.value = files.slice(-1);
currentFile.value = file.raw || null;
};
const handleFileRemove = () => {
fileList.value = [];
currentFile.value = null;
};
const handleUploadConfirm = async () => {
if (!uploadForm.templateType || !uploadForm.templateType.trim()) {
useMessage().error('请填写模板类型编码');
return;
}
if (!currentFile.value) {
useMessage().error('请选择要上传的模板文件');
return;
}
uploading.value = true;
try {
const formData = new FormData();
formData.append('type', uploadForm.templateType.trim());
if (uploadForm.templateTitle && uploadForm.templateTitle.trim()) {
formData.append('title', uploadForm.templateTitle.trim());
}
formData.append('file', currentFile.value);
await uploadTemplate(formData);
useMessage().success('模板上传成功');
uploadDialogVisible.value = false;
await fetchData();
} catch (e) {
useMessage().error('模板上传失败');
} finally {
uploading.value = false;
}
};
const handleDownload = async (row: any) => {
if (!row?.templateType) {
useMessage().error('缺少模板类型编码');
return;
}
const url = getTemplateDownloadUrl(row.templateType);
const fileName = row.templateName || row.templateTitle || row.templateType;
try {
await (window as any).other?.downBlobFile?.(url, {}, fileName) ||
// 兼容直接使用工具函数
(await import('/@/utils/other')).default.downBlobFile(url, {}, fileName);
} catch (e) {
// 如果工具函数不可用,则退回 window.open
window.open(url, '_blank');
}
};
const openEditDialog = (row: any) => {
editDialogVisible.value = true;
editForm.id = row?.id ?? null;
editForm.templateType = row?.templateType || '';
editForm.templateTitle = row?.templateTitle || '';
};
const handleEditConfirm = async () => {
if (!editForm.id) {
useMessage().error('缺少模板ID');
return;
}
if (!editForm.templateTitle || !editForm.templateTitle.trim()) {
useMessage().error('请输入模板类型名称');
return;
}
editing.value = true;
try {
await updateTemplateTitle({
id: editForm.id,
templateTitle: editForm.templateTitle.trim(),
});
useMessage().success('保存成功');
editDialogVisible.value = false;
await fetchData();
} catch (e) {
useMessage().error('保存失败');
} finally {
editing.value = false;
}
};
</script>
<style scoped>
.upload-block {
display: inline-block;
}
</style>