Files
KCGL/inventory-web/src/views/stock/inbound/service.vue
dxc afcf90a859 feat: enforce field-level permissions for buy and service modules
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 15:03:44 +08:00

590 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div style="padding: 20px;">
<h2 style="margin-bottom: 20px;">服务权益管理</h2>
<div class="header-toolbar">
<el-form :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="SKU/物料名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px;"
/>
</el-form-item>
<el-form-item label="服务商">
<el-input
v-model="searchForm.provider_name"
placeholder="服务商名称"
clearable
@keyup.enter="handleSearch"
style="width: 200px;"
/>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker
v-model="searchForm.start_date"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
/>
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker
v-model="searchForm.end_date"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button v-if="userStore.hasPermission('inbound_service:operation')" type="success" @click="handleAdd">新增服务</el-button>
</el-form-item>
</el-form>
</div>
<el-table :data="tableData" border stripe style="width: 100%;" v-loading="loading">
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="200" />
<el-table-column v-if="hasColumnPermission('material_name')" prop="material_name" label="物料名称" />
<el-table-column v-if="hasColumnPermission('provider_name')" prop="provider_name" label="服务商" width="150" />
<el-table-column v-if="hasColumnPermission('sale_price')" prop="sale_price" label="售价" width="120">
<template #default="{row}">{{ row.sale_price.toFixed(2) }}</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('description')" prop="description" label="简介" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('created_at')" prop="created_at" label="创建时间" width="160" />
<el-table-column v-if="userStore.hasPermission('inbound_service:operation')" label="操作" width="180" fixed="right">
<template #default="{row}">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 20px; text-align: center;">
<el-pagination
v-model:current-page="page"
v-model:page-size="perPage"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="700px"
@close="resetDialog"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<div class="dialog-scroll-container">
<div class="form-card basic-card">
<div class="card-title">
<el-icon class="icon"><Box /></el-icon>
<span>1. 基础信息</span>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
</div>
<div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 15px;">
<el-col :span="24">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select
v-model="form.base_id"
filterable
remote
reserve-keyword
placeholder="输入名称或规格..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
>
<el-option
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="option-item">
<span class="opt-name">{{ item.name }}</span>
<span class="opt-spec">{{ item.spec }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24" style="margin-top: -10px; margin-bottom: 10px;">
<span class="search-tip">
<el-icon><InfoFilled/></el-icon> 未输入时展示最新物料输入关键词进行精确搜索
</span>
</el-col>
</el-row>
<div class="read-only-grid" v-if="form.base_id">
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="名称" v-if="hasFormFieldPermission('material_name')"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型" v-if="hasFormFieldPermission('material_type')"><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别" v-if="hasFormFieldPermission('category')"><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号" v-if="hasFormFieldPermission('spec_model')"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位" v-if="hasFormFieldPermission('unit')"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col>
</el-row>
</div>
</div>
</div>
<div class="form-card inbound-card">
<div class="card-title">
<el-icon class="icon"><House /></el-icon>
<span>2. 服务详情</span>
</div>
<div class="card-content">
<el-form-item label="售价" prop="sale_price" v-if="hasFormFieldPermission('sale_price')">
<el-input-number
v-model="form.sale_price"
placeholder="请输入售价"
:controls="false"
:precision="2"
:min="0"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="服务商" prop="provider_name" v-if="hasFormFieldPermission('provider_name')">
<el-autocomplete
v-model="form.provider_name"
:fetch-suggestions="querySearchProvider"
placeholder="输入或选择服务商"
style="width: 100%"
clearable
:trigger-on-focus="true"
@select="handleProviderSelect"
/>
</el-form-item>
<el-form-item label="简介" prop="description" v-if="hasFormFieldPermission('description')">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入服务简介"
/>
</el-form-item>
</div>
</div>
</div>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleDialogConfirm">确认</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { InfoFilled, Box, House } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import {
getServiceList,
createService,
updateService,
deleteService,
searchMaterialBase,
getProviderSuggestions,
getUserSuggestions,
type ServiceItem,
type ServiceQueryParams,
type ServiceCreateRequest,
type MaterialBaseItem
} from '@/api/inbound/service'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
sku: 'inbound_service:sku',
material_name: 'inbound_service:material_name',
provider_name: 'inbound_service:provider_name',
sale_price: 'inbound_service:sale_price',
description: 'inbound_service:description',
created_at: 'inbound_service:created_at',
}
// 检查列权限
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 hasFormFieldPermission = (fieldName: string) => {
// 超级管理员直接返回true
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
// 根据字段名映射到权限码
const map: Record<string, string> = {
base_id: 'inbound_service:base_id',
material_name: 'inbound_service:material_name',
spec_model: 'inbound_service:spec_model',
category: 'inbound_service:category',
material_type: 'inbound_service:material_type',
unit: 'inbound_service:unit',
sale_price: 'inbound_service:sale_price',
provider_name: 'inbound_service:provider_name',
description: 'inbound_service:description',
}
const code = map[fieldName]
if (!code) {
// 没有映射的字段默认显示
return true
}
return userStore.hasPermission(code)
}
// 表格数据
const tableData = ref<ServiceItem[]>([])
const loading = ref(false)
const page = ref(1)
const perPage = ref(20)
const total = ref(0)
const materialOptions = ref<any[]>([])
const searchLoading = ref(false)
const searchForm = reactive({
keyword: '',
provider_name: '',
start_date: '',
end_date: ''
})
// 加载列表
const loadData = async () => {
loading.value = true
try {
const params: ServiceQueryParams = {
page: page.value,
per_page: perPage.value,
keyword: searchForm.keyword || undefined,
provider_name: searchForm.provider_name || undefined,
start_date: searchForm.start_date || undefined,
end_date: searchForm.end_date || undefined
}
const res = await getServiceList(params)
if (res.code === 200) {
tableData.value = res.data.items
total.value = res.data.total
} else {
ElMessage.error(res.msg || '加载失败')
}
} catch (error) {
ElMessage.error('网络错误')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
page.value = 1
loadData()
}
const resetSearch = () => {
Object.assign(searchForm, {
keyword: '',
provider_name: '',
start_date: '',
end_date: ''
})
page.value = 1
loadData()
}
const handleSizeChange = (val: number) => {
perPage.value = val
loadData()
}
const handlePageChange = (val: number) => {
page.value = val
loadData()
}
const handleMaterialDropdownVisible = (visible: boolean) => {
if (visible && materialOptions.value.length === 0) {
handleSearchMaterial('')
}
}
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true
try {
const res = await searchMaterialBase(query)
if (res.code === 200) {
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults
} else {
materialOptions.value = []
}
} catch (error) {
materialOptions.value = []
} finally {
searchLoading.value = false
}
}
const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
form.material_name = item.name
form.spec_model = item.spec
form.category = item.category
form.unit = item.unit
form.material_type = item.type
}
}
// 服务商建议
const fetchProviderSuggestions = async (query: string, cb: any) => {
if (!form.base_id) {
cb([])
return
}
try {
const res: any = await getProviderSuggestions({ base_id: form.base_id })
if (res.code === 200) {
const providers = res.data.map((name: string) => ({ value: name }))
const filtered = query ? providers.filter((item: any) => item.value.toLowerCase().includes(query.toLowerCase())) : providers
cb(filtered)
} else {
cb([])
}
} catch (e) {
cb([])
}
}
const querySearchProvider = (qs: string, cb: any) => fetchProviderSuggestions(qs, cb)
const handleProviderSelect = (item: any) => {
form.provider_name = item.value
}
// 弹窗相关
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogStatus = ref<'create' | 'update'>('create')
const formRef = ref<FormInstance>()
const form = reactive({
id: 0,
base_id: undefined as number | undefined,
material_name: '',
spec_model: '',
category: '',
unit: '',
material_type: '',
sale_price: 0,
provider_name: '',
description: ''
})
const rules = reactive<FormRules>({
base_id: [
{ required: true, message: '请选择基础物料', trigger: 'change' }
],
sale_price: [
{ required: true, message: '请输入售价', trigger: 'blur' },
{ type: 'number', min: 0, message: '售价不能为负数', trigger: 'blur' }
],
provider_name: [
{ required: true, message: '请输入服务商名称', trigger: 'blur' }
]
})
const handleAdd = () => {
dialogTitle.value = '新增服务'
dialogStatus.value = 'create'
Object.assign(form, {
id: 0,
base_id: undefined as number | undefined,
material_name: '',
spec_model: '',
category: '',
unit: '',
material_type: '',
sale_price: 0,
provider_name: '',
description: ''
})
materialOptions.value = []
dialogVisible.value = true
}
const handleEdit = (row: ServiceItem) => {
dialogTitle.value = '编辑服务'
dialogStatus.value = 'update'
Object.assign(form, {
id: row.id,
base_id: row.base_id,
material_name: row.material_name || '',
spec_model: row.spec_model || '',
category: row.category || '',
unit: row.unit || '',
material_type: row.material_type || '',
sale_price: row.sale_price,
provider_name: row.provider_name,
description: row.description
})
dialogVisible.value = true
}
const handleDialogConfirm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
try {
const reqData: ServiceCreateRequest = {
base_id: form.base_id,
sale_price: form.sale_price,
provider_name: form.provider_name,
description: form.description
}
if (form.id === 0) {
await createService(reqData)
ElMessage.success('创建成功')
} else {
await updateService(form.id, reqData)
ElMessage.success('更新成功')
}
dialogVisible.value = false
loadData()
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || '操作失败')
}
})
}
const resetDialog = () => {
formRef.value?.clearValidate()
}
const handleDelete = (row: ServiceItem) => {
ElMessageBox.confirm(`确定删除服务权益 "${row.sku}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await deleteService(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error: any) {
ElMessage.error(error?.response?.data?.msg || '删除失败')
}
}).catch(() => {})
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.dialog-scroll-container {
padding: 15px 20px;
max-height: 70vh;
overflow-y: auto;
overflow-x: hidden;
}
.form-card {
background: #fff;
border-radius: 8px;
border: 1px solid #e4e7ed;
margin-bottom: 15px;
overflow: hidden;
}
.card-title {
background: #fcfcfc;
padding: 10px 20px;
border-bottom: 1px solid #ebeef5;
font-weight: 600;
font-size: 14px;
color: #303133;
display: flex;
align-items: center;
}
.card-title .icon {
margin-right: 8px;
font-size: 18px;
color: #409EFF;
}
.card-title .sub-title {
font-size: 12px;
color: #909399;
font-weight: normal;
margin-left: 10px;
}
.card-content {
padding: 15px 20px;
}
.basic-card {
border-left: 4px solid #409EFF;
}
.inbound-card {
border-left: 4px solid #67C23A;
}
.is-text-view :deep(.el-input__wrapper) {
box-shadow: none !important;
background-color: #f5f7fa;
border-bottom: 1px solid #dcdfe6;
border-radius: 0;
padding-left: 0;
}
.is-text-view :deep(.el-input__inner) {
color: #606266;
font-weight: 500;
font-size: 13px;
}
.read-only-grid {
margin-top: 15px;
}
.search-tip {
color: #909399;
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.highlight-label :deep(.el-form-item__label) {
font-weight: 600;
color: #409EFF;
}
/* Material search options matching buy/semi style */
.option-item {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
}
.opt-name {
font-weight: bold;
}
.opt-spec {
color: #8492a6;
font-size: 13px;
margin-right: 10px;
}
</style>