feat(ui): 按钮级权限守卫与数量显示格式化

按钮级权限(与后端 @permission_required 用同一批权限码):
- 出库记录页「退回」按钮   -> v-permission="'outbound_return'"
- 不良品看板「修复回库」   -> v-permission="'defective_restock'"
- 不良品看板「报废销毁」   -> v-permission="'defective_scrap'"

  两个处置按钮是**独立权限**,可能有人只有其一,故分别判定、分别隐藏;
  两者都无时显示「无处置权限」。均保留响应式的 v-if 兜底——v-permission
  只在 mounted 执行一次,而 Element Plus 表格行会重渲染,两者用同一权限码
  判定一致。hasPermission() 内部已对 SUPER_ADMIN 放行,与后端装饰器的
  超管旁路对齐。

  另外去掉了出库记录页原本套用的 username === 'IRIS' 特例放行:退回是实物
  交接的 SOP 动作,后端不认这个特例,前端也不该认,否则会出现「按钮可见但
  接口 403」的错位。

数量显示格式化(新增 utils/format.ts,该文件原本是 0 字节空文件):
- formatQty()    显示用:1.0000 -> '1',2.5000 -> '2.5'
- normalizeQty() 数值用:同上但返回 number,供 el-input-number 的
                 v-model / :max —— 喂字符串会让步进与边界比较退化

  应用于 不良品看板(退回总数、剩余待处理、两个弹窗的在管数量与输入框)、
  出库记录页(已退 N 提示、退回弹窗三项数字)、退回记录页(退回数量列)。

  同时移除三个 el-input-number 上的 :precision="4" —— 它会把 1 强制渲染成
  1.0000,是截图上多余小数点的直接来源。数量本身仍支持小数(后端按
  numeric(19,4) 收),只是不再强制补零。

  表格列原本不受影响(接口返回 JSON 数字,1.0 经 JS 解析即 1),但后端有一批
  运算是 float 累加,可能吐出 0.30000000000000004 这类尾巴,格式化按
  numeric(19,4) 量纲先收敛到 4 位再做归一。
This commit is contained in:
yueli
2026-09-16 16:46:20 +08:00
parent 27436f5efe
commit 511e822d12
3 changed files with 122 additions and 37 deletions

View File

@ -122,11 +122,18 @@
</el-table-column>
<!-- ★ 原单退回:良品加回库存 / 不良品转入异常待处理台账。
已退满的行(returnable_quantity <= 0)按钮置灰不可点。 -->
已退满的行(returnable_quantity <= 0)按钮置灰不可点。
★ 权限双重保障,两者用同一个权限码 outbound_return,判定一致:
· v-if="canReturn" 响应式判定。v-permission 指令只在 mounted
执行一次,而表格行会重渲染,用响应式判定兜底更稳。
· v-permission 本系统规范的按钮级指令,声明式表达权限要求,
无权限时直接把元素移出 DOM(普通领料员工完全看不到)。 -->
<el-table-column label="退回" width="150" align="center" fixed="right">
<template #default="{ row }">
<template v-if="canReturn">
<el-button
v-permission="'outbound_return'"
type="warning"
link
size="small"
@ -136,7 +143,7 @@
退回
</el-button>
<span v-if="row.returned_quantity > 0" class="returned-hint">
已退 {{ row.returned_quantity }}
已退 {{ formatQty(row.returned_quantity) }}
</span>
</template>
<span v-else class="returned-hint">-</span>
@ -222,23 +229,24 @@
</span>
</el-form-item>
<el-form-item label="原出库数量">
<span class="dialog-text">{{ returnDialog.row?.quantity ?? 0 }}</span>
<span class="dialog-text">{{ formatQty(returnDialog.row?.quantity) }}</span>
</el-form-item>
<el-form-item label="已退回数量">
<span class="dialog-text">{{ returnDialog.row?.returned_quantity ?? 0 }}</span>
<span class="dialog-text">{{ formatQty(returnDialog.row?.returned_quantity) }}</span>
</el-form-item>
<el-form-item label="本次可退最大">
<span class="dialog-text dialog-strong">
{{ returnDialog.row?.returnable_quantity ?? 0 }}
{{ formatQty(returnDialog.row?.returnable_quantity) }}
</span>
</el-form-item>
<el-form-item label="本次退回数量" required>
<!-- 不再设 :precision="4" —— 那会把 1 显示成 1.0000。
数量本身仍支持小数(后端按 numeric(19,4) 收)。 -->
<el-input-number
v-model="returnDialog.form.return_qty"
:min="0"
:max="returnDialog.row?.returnable_quantity || 0"
:precision="4"
:max="normalizeQty(returnDialog.row?.returnable_quantity)"
:step="1"
style="width: 200px"
/>
@ -279,19 +287,22 @@ import { ref, computed, onMounted, reactive, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { getOutboundList } from '@/api/outbound'
import { returnFromOutbound } from '@/api/inbound/return'
import { formatQty, normalizeQty } from '@/utils/format'
import { Picture } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import CompanySelector from '@/components/CompanySelector.vue'
const userStore = useUserStore()
// 退回按钮的可见性。后端同样会校验权限(inventory_stocktake:operation),
// 前端只是不给无权限用户显示一个必然失败的按钮。
const canReturn = computed(() =>
userStore.role === 'SUPER_ADMIN'
|| userStore.username === 'IRIS'
|| userStore.hasPermission('inventory_stocktake:operation')
)
// 退回按钮的可见性。
//
// ★ 权限码必须与后端 @permission_required('outbound_return') 逐字一致,
// 且刻意**不**套用本文件 hasColumnPermission() 里 `username === 'IRIS'`
// 那类特例放行 —— 退回是实物交接的 SOP 动作,后端不认特例,前端也不该认,
// 否则会出现「按钮可见但接口 403」的错位。
// hasPermission() 内部已对 SUPER_ADMIN 放行,与后端装饰器的超管旁路对齐。
// 权限码来源见 db_migrations/add_outbound_return_perm.sql
const canReturn = computed(() => userStore.hasPermission('outbound_return'))
// 防抖定时器
let debounceTimer: ReturnType<typeof setTimeout> | null = null
@ -496,7 +507,8 @@ const returnDialog = reactive({
const openReturnDialog = (row: any) => {
returnDialog.row = row
// 默认带入「本次可退最大」,业务上整行退回最常见;用户可再调小做部分退回
returnDialog.form.return_qty = Number(row?.returnable_quantity || 0)
// 用 normalizeQty 抹掉后端 float 累加留下的尾数,避免输入框显示一长串小数
returnDialog.form.return_qty = normalizeQty(row?.returnable_quantity)
returnDialog.form.is_defective = false
returnDialog.form.reason = ''
returnDialog.visible = true
@ -513,8 +525,8 @@ const resetReturnDialog = () => {
const submitReturn = async () => {
if (returnDialog.submitting) return // 双保险:即便按钮 loading 被绕过也不重复提交
const maxQty = Number(returnDialog.row?.returnable_quantity || 0)
const qty = Number(returnDialog.form.return_qty || 0)
const maxQty = normalizeQty(returnDialog.row?.returnable_quantity)
const qty = normalizeQty(returnDialog.form.return_qty)
const reason = (returnDialog.form.reason || '').trim()
if (!qty || qty <= 0) {