feat(my-requests): 我的申请单独立模块(采购管理之后)

路由位置
--------
放在「采购管理」之后,作为独立一级模块:

    采购管理
    我的申请单      ← 新增(icon: Tickets)
    借库管理

★ 独立模块而非挂在某个业务页下:申请人可能提交出库/借库/报废三类单据,
  放在任一业务模块下都会让其它模块的人找不到。

页面功能
--------
  · 类型筛选:全部 / 出库 / 借库 / 报废
  · 状态筛选:全部 / 待审批 / 已通过 / 已驳回 / 已完成 / 已撤回
  · 展开行显示物料明细(数量字段已由后端归一化)
  · 撤回按钮仅对 status 0/1 可见(已预占但未执行)

撤回不硬编码 URL —— 使用后端回传的 withdraw_endpoint 分发:

    const res = await request({ url: row.withdraw_endpoint, method: 'post' })

这样将来模块端点调整(如借库从 close 换成正式 withdraw),前端无需改动。

权限说明
--------
菜单不做权限过滤(Sidebar 只过滤 meta.hidden),所有角色都会看到本页。
这正是预期 —— 申请人本就不应需要任何权限码。数据由后端强制按
applicant_id 过滤,看不到他人单据。

顺带清理
--------
移除先前加在 Selection.vue 的重复实现(约 110 行模板/脚本/样式),
改为一个跳转链接「查看我的申请单 >」;并移除随之失效的 3 个导入
(onMounted / Refresh / 两个 API 函数)。

验证:新页面 SFC 编译零告警;Selection.vue 清理后编译通过。
This commit is contained in:
yueli
2026-09-10 15:37:11 +08:00
parent b67d577616
commit 20d49c0b0d
4 changed files with 307 additions and 1 deletions

View File

@ -195,3 +195,47 @@ export function getStockAlternatives(
}
})
}
/**
* 我的申请单(申请人视角)
*
* 走专用端点,仅需登录态 —— 服务端硬编码 applicant_id = 当前用户,
* 普通申请人无需持有 outbound_approval那是管理权限即可查看自己的单据。
*/
export function getMyOutboundRequests(params: { page?: number; limit?: number; status?: number | '' }) {
return request({
url: '/v1/outbound/my-requests',
method: 'get',
params
})
}
/**
* 撤回自己的申请单(待审批 或 已通过但未执行)
* 服务端会断言单据归属,非本人且非管理者将返回 403。
*/
export function withdrawOutboundRequest(id: number) {
return request({
url: `/v1/outbound/request/${id}/withdraw`,
method: 'post'
})
}
/**
* 我的申请单(跨模块聚合:出库 + 借库 + 报废)
*
* 走专用的 /my-requests 聚合端点,仅需登录态 —— 服务端硬编码
* applicant_id = 当前用户,普通员工无需任何审批类权限即可查看自己的单据。
*/
export function getMyRequests(params: {
page?: number
limit?: number
status?: number | ''
type?: 'all' | 'outbound' | 'borrow' | 'scrap'
}) {
return request({
url: '/v1/my-requests',
method: 'get',
params
})
}

View File

@ -201,6 +201,25 @@ const routes: Array<RouteRecordRaw> = [
]
},
// 6.0 我的申请单(申请人视角,跨模块聚合)
//
// ★ 独立模块而非挂在某个业务页下:申请人可能提交出库/借库/报废三类单据,
// 放在任一业务模块下都会让其它模块的人找不到。
// 本页数据来自 /my-requests 聚合端点(仅需登录态,服务端硬编码
// applicant_id=当前用户),因此不需要也不应配置审批类权限码。
{
path: '/my-requests',
component: Layout,
children: [
{
path: '',
name: 'MyRequests',
component: () => import('@/views/my-requests/index.vue'),
meta: { title: '我的申请单', icon: 'Tickets' }
}
]
},
// 6. 借库管理
{
path: '/operation',

View File

@ -0,0 +1,238 @@
<template>
<div class="app-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="title">我的申请单</span>
<span class="subtitle">这里汇总你提交过的全部申请出库 / 借库 / 报废</span>
</div>
</template>
<!-- 筛选 -->
<div class="filter-container">
<span class="filter-label">类型:</span>
<el-radio-group v-model="filterType" @change="handleFilterChange">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="outbound">出库</el-radio-button>
<el-radio-button label="borrow">借库</el-radio-button>
<el-radio-button label="scrap">报废</el-radio-button>
</el-radio-group>
<span class="filter-label" style="margin-left:16px;">状态:</span>
<el-radio-group v-model="filterStatus" @change="handleFilterChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
<el-radio-button :label="4">已撤回</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" :loading="loading" @click="fetchData">刷新</el-button>
</div>
<el-table :data="list" v-loading="loading" border stripe style="width:100%; margin-top:16px">
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-box">
<p class="expand-title">物料明细 {{ row.items?.length || 0 }} </p>
<el-table :data="row.items || []" border size="small" style="width:100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="130" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="130" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<!-- 数量字段已由后端归一化报废的 scrap_qty quantity -->
<el-table-column prop="quantity" label="数量" width="90" align="right" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="typeTagType(row.type)">{{ row.type_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="request_no" label="申请单号" min-width="190" show-overflow-tooltip />
<el-table-column label="物料数" width="80" align="center">
<template #default="{ row }">{{ row.items?.length || 0 }} </template>
</el-table-column>
<el-table-column prop="remark" label="申请原因" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="驳回原因" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.status === 2" style="color:#F56C6C;">{{ row.reject_reason || '-' }}</span>
<span v-else style="color:#c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="提交时间" width="160" align="center">
<template #default="{ row }">
<span>{{ (row.created_at || '').substring(0, 16) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<!-- 待审批(0)已通过(1)可撤回 两者都已预占库存但尚未执行 -->
<el-button
v-if="row.status === 0 || row.status === 1"
type="warning" link size="small"
:loading="row._withdrawing"
@click="handleWithdraw(row)"
>撤回</el-button>
<span v-else style="color:#c0c4cc;">-</span>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && list.length === 0" description="暂无申请单" :image-size="70" />
<el-pagination
v-if="total > 0"
background
style="margin-top:16px; justify-content:flex-end; display:flex;"
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="page"
@current-change="handlePageChange"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { getMyRequests } from '@/api/outbound'
import request from '@/utils/request'
// ============================================================================
// 我的申请单(跨模块聚合)
//
// 数据来自专用聚合端点 /my-requests服务端硬编码 applicant_id = 当前用户,
// 因此本页对任何登录用户可用,且从设计上不可能看到他人单据。
//
// 撤回是**写操作**,保留在各模块自己的端点(三者释放逻辑不同),
// 后端在每条记录上回传 withdraw_endpoint前端按此分发 —— 无需在此
// 硬编码三个模块的 URL 映射。
// ============================================================================
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterType = ref<'all' | 'outbound' | 'borrow' | 'scrap'>('all')
const filterStatus = ref<number | ''>('')
const statusText = (s: number) => {
const map: Record<number, string> = {
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '已撤回',
}
return map[s] ?? '-'
}
const statusTagType = (s: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info', 4: 'info',
}
return map[s] ?? 'info'
}
const typeTagType = (t: string) => {
const map: Record<string, string> = {
outbound: 'primary', borrow: 'warning', scrap: 'danger',
}
return map[t] || 'info'
}
const fetchData = async () => {
loading.value = true
try {
const res: any = await getMyRequests({
page: page.value,
limit: pageSize.value,
type: filterType.value,
status: filterStatus.value,
})
const items = res?.data?.items || []
list.value = items.map((r: any) => ({ ...r, _withdrawing: false }))
total.value = res?.data?.total || 0
} catch (e: any) {
ElMessage.error(e?.msg || '加载我的申请单失败')
} finally {
loading.value = false
}
}
const handleFilterChange = () => {
page.value = 1
fetchData()
}
const handlePageChange = (p: number) => {
page.value = p
fetchData()
}
const handleWithdraw = async (row: any) => {
if (!row.withdraw_endpoint) {
return ElMessage.error('该类型暂不支持撤回')
}
const itemCount = row.items?.length || 0
try {
await ElMessageBox.confirm(
`确定撤回【${row.type_label}${row.request_no} 吗?\n\n` +
`撤回后该单将作废,其预占的 ${itemCount} 项物料库存会立即释放,` +
`可供其它申请使用。\n此操作不可恢复。`,
'⚠️ 撤回确认',
{ confirmButtonText: '确认撤回', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
return
}
row._withdrawing = true
try {
// ★ 按后端回传的端点分发,前端不硬编码三模块 URL 映射
const res: any = await request({ url: row.withdraw_endpoint, method: 'post' })
ElMessage.success(res?.msg || `申请单 ${row.request_no} 已撤回`)
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || err?.response?.data?.msg || '撤回失败')
} finally {
row._withdrawing = false
}
}
onMounted(fetchData)
</script>
<style scoped>
.card-header { display: flex; align-items: baseline; gap: 10px; }
.title { font-size: 17px; font-weight: bold; }
.subtitle { font-size: 12px; color: #909399; }
.filter-container {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.filter-label { font-size: 14px; color: #606266; font-weight: 500; }
.expand-box { padding: 12px 24px; background: #f5f7fa; }
.expand-title { margin: 0 0 10px 0; font-weight: bold; font-size: 13px; color: #606266; }
</style>

View File

@ -115,6 +115,11 @@
<span style="color: red; font-weight: bold;">{{ selectedItems.length }}</span> 种物品
合计出库 <span style="color: red; font-weight: bold;">{{ totalExportCount }}</span>
</div>
<div style="margin-top: 10px; text-align: right;">
<el-button link type="primary" @click="$router.push('/my-requests')">
查看我的申请单 &gt;
</el-button>
</div>
</el-card>
<el-dialog v-model="manualDialogVisible" title="选择库存物品" width="85%" top="5vh" destroy-on-close :close-on-click-modal="false">
@ -1345,10 +1350,10 @@ const confirmExport = () => {
exportLoading.value = false;
}
}
</script>
<style scoped>
/* ================= 屏幕显示样式 ================= */
.card-header { display: flex; justify-content: space-between; align-items: center; }
.header-left .title { font-size: 18px; font-weight: bold; margin-right: 10px; }
.header-left .subtitle { font-size: 12px; color: #909399; }