Compare commits

2 Commits

Author SHA1 Message Date
dxc
42b0cddd3e feat: add column permission checks to transaction records table
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:07:48 +08:00
dxc
a2b1a62132 feat: add RBAC and field masking for borrow/return/records pages
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:05:52 +08:00
4 changed files with 180 additions and 33 deletions

View File

@ -1,14 +1,64 @@
from flask import Blueprint, jsonify, request # .material -> .base refactor checked from flask import Blueprint, jsonify, request # .material -> .base refactor checked
from flask_jwt_extended import jwt_required, get_jwt_identity from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required
from app.services.auth_service import AuthService
from app.services.trans_service import TransService from app.services.trans_service import TransService
import traceback import traceback
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions') trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
claims = get_jwt()
user_role = claims.get('role')
if not user_role:
return []
# 超级管理员返回所有字段权限
if user_role == 'super_admin':
return ['*']
perm_dict = AuthService.get_user_permissions(user_role)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'borrow_no': f'{prefix}:borrow_no',
'borrower_name': f'{prefix}:borrower_name',
'sku': f'{prefix}:sku',
'borrow_time': f'{prefix}:borrow_time',
'return_time': f'{prefix}:return_time',
'status': f'{prefix}:status',
'expected_return_time': f'{prefix}:expected_return_time',
'return_location': f'{prefix}:return_location',
'borrow_signature': f'{prefix}:borrow_signature',
'return_signature': f'{prefix}:return_signature',
}
# 如果用户是超级管理员且有 '*',则不过滤
if '*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
item_dict[field] = None
return item_dict
# --- 借库接口 --- # --- 借库接口 ---
@trans_bp.route('/borrow', methods=['POST']) @trans_bp.route('/borrow', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('op_borrow:operation')
def create_borrow(): def create_borrow():
data = request.get_json() data = request.get_json()
try: try:
@ -21,6 +71,7 @@ def create_borrow():
# --- 还库辅助:扫码查找借出记录 --- # --- 还库辅助:扫码查找借出记录 ---
@trans_bp.route('/return/scan', methods=['GET']) @trans_bp.route('/return/scan', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('op_return')
def scan_borrowed_item(): def scan_borrowed_item():
barcode = request.args.get('barcode') barcode = request.args.get('barcode')
if not barcode: if not barcode:
@ -36,6 +87,7 @@ def scan_borrowed_item():
# --- 还库提交 --- # --- 还库提交 ---
@trans_bp.route('/return', methods=['POST']) @trans_bp.route('/return', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('op_return:operation')
def submit_return(): def submit_return():
data = request.get_json() data = request.get_json()
user = get_jwt_identity() # 库管 user = get_jwt_identity() # 库管
@ -49,10 +101,15 @@ def submit_return():
# --- 记录列表 --- # --- 记录列表 ---
@trans_bp.route('/records', methods=['GET']) @trans_bp.route('/records', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('op_records')
def get_records(): def get_records():
status = request.args.get('status', 'all') status = request.args.get('status', 'all')
page = int(request.args.get('page', 1)) page = int(request.args.get('page', 1))
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword) res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword)
# 字段级脱敏
user_permissions = get_current_user_permissions()
if res.get('items'):
res['items'] = [filter_item_by_permissions(item, user_permissions, 'op_records') for item in res['items']]
return jsonify({'code': 200, 'data': res}) return jsonify({'code': 200, 'data': res})

View File

@ -13,10 +13,14 @@
</template> </template>
<div class="scan-section"> <div class="scan-section">
<div class="camera-placeholder" @click="showCamera = true"> <div v-if="userStore.hasPermission('op_borrow:operation')" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon> <el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span> <span class="text">点击开启全屏扫码</span>
</div> </div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box"> <div class="input-box">
<el-input <el-input
@ -26,12 +30,13 @@
clearable clearable
ref="barcodeRef" ref="barcodeRef"
size="large" size="large"
:disabled="!userStore.hasPermission('op_borrow:operation')"
> >
<template #prefix> <template #prefix>
<el-icon><Scissor /></el-icon> <el-icon><Scissor /></el-icon>
</template> </template>
<template #append> <template #append>
<el-button @click="handleManualInput">添加</el-button> <el-button @click="handleManualInput" :disabled="!userStore.hasPermission('op_borrow:operation')">添加</el-button>
</template> </template>
</el-input> </el-input>
</div> </div>
@ -40,16 +45,16 @@
<div class="cart-section"> <div class="cart-section">
<div v-if="cartItems.length > 0"> <div v-if="cartItems.length > 0">
<el-table :data="cartItems" border stripe style="width: 100%"> <el-table :data="cartItems" border stripe style="width: 100%">
<el-table-column prop="name" label="物品名称" min-width="120" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('name')" prop="name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="可用库存" width="90" align="center"> <el-table-column v-if="hasColumnPermission('available_quantity')" label="可用库存" width="90" align="center">
<template #default="{row}"> <template #default="{row}">
<el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag> <el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="借用数" width="130" align="center"> <el-table-column v-if="hasColumnPermission('out_quantity')" label="借用数" width="130" align="center">
<template #default="{row}"> <template #default="{row}">
<el-input-number <el-input-number
v-model="row.out_quantity" v-model="row.out_quantity"
@ -57,11 +62,12 @@
:max="parseFloat(row.available_quantity)" :max="parseFloat(row.available_quantity)"
size="small" size="small"
style="width: 100px" style="width: 100px"
:disabled="!userStore.hasPermission('op_borrow:operation')"
/> />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right"> <el-table-column v-if="userStore.hasPermission('op_borrow:operation')" label="操作" width="60" align="center" fixed="right">
<template #default="{$index}"> <template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" /> <el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
</template> </template>
@ -102,7 +108,7 @@
</el-form-item> </el-form-item>
<el-form-item label="领用人签名确认" required> <el-form-item label="领用人签名确认" required>
<div class="signature-box" @click="openSignatureDialog"> <div class="signature-box" @click="openSignatureDialog" v-if="userStore.hasPermission('op_borrow:operation')">
<div v-if="signaturePreviewUrl" class="signed-img"> <div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" /> <img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span> <span class="re-sign-tip">点击重签</span>
@ -112,11 +118,17 @@
<span>点击此处进行全屏签名</span> <span>点击此处进行全屏签名</span>
</div> </div>
</div> </div>
<div v-else class="signature-box" style="background-color: #f5f5f5; cursor: not-allowed;">
<div class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>无签名权限</span>
</div>
</div>
</el-form-item> </el-form-item>
<div class="bottom-actions"> <div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button> <el-button v-if="userStore.hasPermission('op_borrow:operation')" @click="clearAll" icon="Refresh">清空</el-button>
<el-button type="primary" size="large" :loading="loading" @click="submitForm" icon="Select"> <el-button v-if="userStore.hasPermission('op_borrow:operation')" type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
确认借出 确认借出
</el-button> </el-button>
</div> </div>
@ -187,6 +199,27 @@ import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode } from '@/api/outbound' import { getStockByBarcode } from '@/api/outbound'
import request from '@/utils/request' import request from '@/utils/request'
import { uploadFile } from '@/api/common/upload' import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
borrower_name: 'op_borrow:borrower_name',
sku: 'op_borrow:sku',
available_quantity: 'op_borrow:available_quantity',
out_quantity: 'op_borrow:out_quantity',
// 其他字段可根据需要添加
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
// --- 状态定义 --- // --- 状态定义 ---
const barcodeInput = ref('') const barcodeInput = ref('')

View File

@ -19,12 +19,12 @@
v-loading="loading" v-loading="loading"
:row-class-name="tableRowClassName" :row-class-name="tableRowClassName"
> >
<el-table-column prop="borrow_no" label="单号" width="180" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column prop="borrower_name" label="借用人" width="100" /> <el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="100" />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column prop="borrow_time" label="借出时间" width="160" sortable /> <el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" sortable />
<el-table-column label="归还时间 / 预计" min-width="200"> <el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
<template #default="{row}"> <template #default="{row}">
<div v-if="row.status === 'returned'"> <div v-if="row.status === 'returned'">
<el-tag type="success" size="small">实际</el-tag> <el-tag type="success" size="small">实际</el-tag>
@ -40,7 +40,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="100" align="center"> <el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{row}"> <template #default="{row}">
<el-tag :type="row.status==='returned'?'success':'warning'"> <el-tag :type="row.status==='returned'?'success':'warning'">
{{ row.status==='returned'?'已还':'借出中' }} {{ row.status==='returned'?'已还':'借出中' }}
@ -48,22 +48,22 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="归还库位" min-width="120"> <el-table-column v-if="hasColumnPermission('return_location')" label="归还库位" min-width="120">
<template #default="{row}"> <template #default="{row}">
<span v-if="row.return_location">{{ row.return_location }}</span> <span v-if="row.return_location">{{ row.return_location }}</span>
<span v-else style="color:#ccc">-</span> <span v-else style="color:#ccc">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="电子签名" width="140" align="center"> <el-table-column v-if="hasColumnPermission('borrow_signature') || hasColumnPermission('return_signature')" label="电子签名" width="140" align="center">
<template #default="{row}"> <template #default="{row}">
<div style="display:flex; justify-content: center; gap:10px"> <div style="display:flex; justify-content: center; gap:10px">
<el-popover trigger="hover" placement="top" v-if="row.borrow_signature" width="220"> <el-popover trigger="hover" placement="top" v-if="row.borrow_signature && hasColumnPermission('borrow_signature')" width="220">
<template #reference><el-tag size="small"></el-tag></template> <template #reference><el-tag size="small"></el-tag></template>
<img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" /> <img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" />
</el-popover> </el-popover>
<el-popover trigger="hover" placement="top" v-if="row.return_signature" width="220"> <el-popover trigger="hover" placement="top" v-if="row.return_signature && hasColumnPermission('return_signature')" width="220">
<template #reference><el-tag type="success" size="small"></el-tag></template> <template #reference><el-tag type="success" size="small"></el-tag></template>
<img :src="row.return_signature" style="width:200px; border:1px solid #eee" /> <img :src="row.return_signature" style="width:200px; border:1px solid #eee" />
</el-popover> </el-popover>
@ -88,6 +88,32 @@ import request from '@/utils/request'
import dayjs from 'dayjs' // 建议使用 dayjs 处理日期,如果没有安装,可以用原生 Date import dayjs from 'dayjs' // 建议使用 dayjs 处理日期,如果没有安装,可以用原生 Date
import 'dayjs/locale/zh-cn' // 导入中文包 import 'dayjs/locale/zh-cn' // 导入中文包
dayjs.locale('zh-cn') dayjs.locale('zh-cn')
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
borrow_no: 'op_records:borrow_no',
borrower_name: 'op_records:borrower_name',
sku: 'op_records:sku',
borrow_time: 'op_records:borrow_time',
return_time: 'op_records:return_time',
status: 'op_records:status',
expected_return_time: 'op_records:expected_return_time',
return_location: 'op_records:return_location',
borrow_signature: 'op_records:borrow_signature',
return_signature: 'op_records:return_signature',
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
const list = ref<any[]>([]) const list = ref<any[]>([])
const total = ref(0) const total = ref(0)

View File

@ -13,10 +13,14 @@
</template> </template>
<div class="scan-section"> <div class="scan-section">
<div class="camera-placeholder" @click="showCamera = true"> <div v-if="userStore.hasPermission('op_return:operation')" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon> <el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span> <span class="text">点击开启全屏扫码</span>
</div> </div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box"> <div class="input-box">
<el-input <el-input
@ -26,12 +30,13 @@
clearable clearable
ref="barcodeRef" ref="barcodeRef"
size="large" size="large"
:disabled="!userStore.hasPermission('op_return:operation')"
> >
<template #prefix> <template #prefix>
<el-icon><Scissor /></el-icon> <el-icon><Scissor /></el-icon>
</template> </template>
<template #append> <template #append>
<el-button @click="scanItem">识别</el-button> <el-button @click="scanItem" :disabled="!userStore.hasPermission('op_return:operation')">识别</el-button>
</template> </template>
</el-input> </el-input>
</div> </div>
@ -40,16 +45,17 @@
<div class="cart-section"> <div class="cart-section">
<div v-if="returnList.length > 0"> <div v-if="returnList.length > 0">
<el-table :data="returnList" border stripe style="width: 100%"> <el-table :data="returnList" border stripe style="width: 100%">
<el-table-column prop="borrower_name" label="借用人" width="90" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="90" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="归还库位(可改)" min-width="160"> <el-table-column v-if="hasColumnPermission('return_location')" label="归还库位(可改)" min-width="160">
<template #default="{row}"> <template #default="{row}">
<el-input <el-input
v-model="row.return_location" v-model="row.return_location"
:placeholder="`原: ${row.current_location || '无'}`" :placeholder="`原: ${row.current_location || '无'}`"
clearable clearable
size="small" size="small"
:disabled="!userStore.hasPermission('op_return:operation')"
> >
<template #append v-if="row.return_location !== row.current_location"> <template #append v-if="row.return_location !== row.current_location">
<span style="color: #E6A23C; font-size: 12px;">变更</span> <span style="color: #E6A23C; font-size: 12px;">变更</span>
@ -58,7 +64,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right"> <el-table-column v-if="userStore.hasPermission('op_return:operation')" label="操作" width="60" align="center" fixed="right">
<template #default="{$index}"> <template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="returnList.splice($index, 1)" /> <el-button type="danger" icon="Delete" circle size="small" @click="returnList.splice($index, 1)" />
</template> </template>
@ -77,7 +83,7 @@
<el-form label-position="top"> <el-form label-position="top">
<el-form-item required> <el-form-item required>
<div class="signature-box" @click="openSignatureDialog"> <div class="signature-box" @click="openSignatureDialog" v-if="userStore.hasPermission('op_return:operation')">
<div v-if="signaturePreviewUrl" class="signed-img"> <div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" /> <img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span> <span class="re-sign-tip">点击重签</span>
@ -87,12 +93,18 @@
<span>点击此处进行库管签名</span> <span>点击此处进行库管签名</span>
</div> </div>
</div> </div>
<div v-else class="signature-box" style="background-color: #f5f5f5; cursor: not-allowed;">
<div class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>无签名权限</span>
</div>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<div class="bottom-actions"> <div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button> <el-button v-if="userStore.hasPermission('op_return:operation')" @click="clearAll" icon="Refresh">清空</el-button>
<el-button type="success" size="large" :loading="loading" @click="preSubmitCheck" icon="Select"> <el-button v-if="userStore.hasPermission('op_return:operation')" type="success" size="large" :loading="loading" @click="preSubmitCheck" icon="Select">
确认归还 确认归还
</el-button> </el-button>
</div> </div>
@ -161,6 +173,25 @@ import { uploadFile } from '@/api/common/upload'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue' import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue' import QrScanner from '@/components/QrScanner/index.vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
borrower_name: 'op_return:borrower_name',
sku: 'op_return:sku',
return_location: 'op_return:return_location',
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
// --- 状态 --- // --- 状态 ---
const barcode = ref('') const barcode = ref('')