This commit is contained in:
zlwu committed 2026-06-29 11:15:39 +08:00
1 parent 493f8d6756
commit d84f4b944c
406 files changed
+58954 -10376

No files matched your search

@@ -224,7 +224,7 @@
<el-icon><Upload /></el-icon>提交
</el-button>
<el-button
v-if="row.status === 'draft' || row.status === 'rejected'"
v-if="(row.status === 'draft' || row.status === 'rejected') && canDeleteByCreator(row)"
class="btn-op-item"
:loading="loadingMap[`del_${row.id}`]"
@click="handleDelete(row)"
@@ -433,6 +433,7 @@ import {
ArrowDownBold
} from '@element-plus/icons-vue'
import { advanceApi, paymentApi } from '@/api/bx'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
statusLabel,
statusTagType,
@@ -758,6 +759,10 @@ async function handleSubmit(row: any) {
}
async function handleDelete(row: any) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
try {
await ElMessageBox.confirm('确定要删除该申请单吗?删除后不可恢复。', '删除确认', {
confirmButtonText: '确定删除',
+12 -1
View File
@@ -124,7 +124,13 @@
>
<el-icon><Plus /></el-icon>新增子项
</el-button>
<el-button class="btn-op-item" :loading="loadingMap.rowDelete" @click="handleDelete(row)">
<el-button
class="btn-op-item"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
:loading="loadingMap.rowDelete"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
@@ -325,6 +331,7 @@ import {
SortDown
} from '@element-plus/icons-vue'
import { expenseItemApi } from '@/api/bx'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { statusLabel, statusTagType } from '@/utils/bx/format'
import { suggestExpenseItemCode } from '@/utils/bx/expenseItemCode'
@@ -512,6 +519,10 @@ async function handleAddChildSubmit() {
}
async function handleDelete(row: any) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm(
`确定要删除费用项目「${row.itemName || row.itemCode}」吗?`,
'删除确认',
@@ -168,7 +168,7 @@
<el-icon><Upload /></el-icon>提交
</el-button>
<el-button
v-if="row.status === 'draft' || row.status === 'rejected'"
v-if="(row.status === 'draft' || row.status === 'rejected') && canDeleteByCreator(row)"
class="btn-op-item"
:loading="loadingMap[`del_${row.id}`]"
@click="handleDelete(row)"
@@ -474,6 +474,7 @@ import {
} from '@element-plus/icons-vue'
import { reimburseApi, attachmentApi } from '@/api/bx'
import { type BxAttachmentItem, loadBxAttachments } from '@/utils/bx/attachment'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { useExpenseItemTree, type ExpenseItem } from '@/hooks/bx/useExpenseItemTree'
import {
statusLabel,
@@ -911,6 +912,10 @@ async function handleSubmit(row: any) {
}
async function handleDelete(row: any) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
try {
await ElMessageBox.confirm('确定要删除该报销单吗?删除后不可恢复。', '删除确认', {
confirmButtonText: '确定删除',
@@ -64,7 +64,13 @@
<el-button class="btn-op-item" @click="openEditDialog(row)">
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button class="btn-op-item" :loading="loadingMap.rowDelete" @click="deleteNode(row)">
<el-button
class="btn-op-item"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
:loading="loadingMap.rowDelete"
@click="deleteNode(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-op-item" @click="moveUp(row)">
@@ -180,6 +186,7 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
@@ -365,6 +372,10 @@ async function submitEditNode() {
}
async function deleteNode(node: Record<string, any>) {
if (!canDeleteByCreator(node)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm(`确定要删除节点「${node.nodeName}」吗?`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
@@ -64,7 +64,13 @@
<el-button class="btn-op-item" @click="openEditDialog(row)">
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button class="btn-op-item" :loading="loadingMap.rowDelete" @click="deleteNode(row)">
<el-button
class="btn-op-item"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
:loading="loadingMap.rowDelete"
@click="deleteNode(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-op-item" @click="moveUp(row)">
@@ -180,6 +186,7 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
@@ -365,6 +372,10 @@ async function submitEditNode() {
}
async function deleteNode(node: Record<string, any>) {
if (!canDeleteByCreator(node)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm(`确定要删除节点「${node.nodeName}」吗?`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
@@ -40,7 +40,8 @@
v-if="hasCluePerm('remove')"
class="btn-ghost"
type="danger"
:disabled="clueInfo.isConverted === 1"
:disabled="clueInfo.isConverted === 1 || !canDeleteByCreator(clueInfo)"
:title="canDeleteByCreator(clueInfo) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>删除
@@ -89,7 +90,7 @@
}}</span>
</div>
<div
v-if="hasCluePerm('follow') && canManageFollow(item)"
v-if="hasCluePerm('follow') && canDeleteByCreator(item)"
class="follow-actions"
>
<el-button link type="primary" size="small" @click="handleEditFollow(item)">
@@ -148,6 +149,7 @@
import type { ClueVO, ClueFollowVO, ClueFollowQuery } from '@/api/crm/clue/types'
import { useDictStore } from '@/store/modules/dict'
import { useCluePerm } from '@/hooks/business/useCluePerm'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import ClueForm from './form.vue'
import FollowForm from './followForm.vue'
@@ -336,11 +338,6 @@
clueFormRef.value?.open(clueId.value)
}
const canManageFollow = (item: ClueFollowVO) => {
if (!item.createBy) return true
return String(item.createBy) === String(currentUserId.value)
}
const handleAddFollow = () => {
followFormRef.value?.open(clueId.value)
}
+13 -1
View File
@@ -406,7 +406,8 @@
<el-button
v-if="hasCluePerm('remove')"
class="btn-op-item danger"
:disabled="row.isConverted === 1"
:disabled="row.isConverted === 1 || !canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
@@ -549,6 +550,7 @@
import type { UploadFile } from 'element-plus'
import { useDictStore } from '@/store/modules/dict'
import { useCluePerm } from '@/hooks/business/useCluePerm'
import { canDeleteByCreator, filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
listClue,
listMyClue,
@@ -823,6 +825,16 @@
ElMessage.warning('请至少选择一条数据进行删除')
return
}
const targets = row ? [row] : selectedRows.value
const deletable = filterDeletableByCreator(targets)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < targets.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('是否确认删除选中的线索?已转化的线索不允许删除。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
@@ -64,7 +64,7 @@
<el-button type="primary" icon="Plus" @click="handleAdd">新增</el-button>
</el-col>
<el-col v-if="hasContractPerm('changeRemove')" :span="1.5">
<el-button type="danger" icon="Delete" :disabled="multiple" @click="handleDelete">删除</el-button>
<el-button type="danger" icon="Delete" :disabled="multiple || !allRowsDeletableByCreator(changeList.filter((item) => ids.includes(item.id)))" :title="allRowsDeletableByCreator(changeList.filter((item) => ids.includes(item.id))) ? '' : CREATOR_DELETE_TIP" @click="handleDelete">删除</el-button>
</el-col>
<el-col v-if="hasContractPerm('changeExport')" :span="1.5">
<el-button type="success" icon="Download" @click="handleExport">导出</el-button>
@@ -135,7 +135,7 @@
icon="RefreshLeft"
>撤回</el-dropdown-item>
<el-dropdown-item
v-if="hasContractPerm('changeRemove') && scope.row.approvalStatus === 'pending'"
v-if="hasContractPerm('changeRemove') && scope.row.approvalStatus === 'pending' && canDeleteByCreator(scope.row)"
command="delete"
icon="Delete"
divided
@@ -238,6 +238,7 @@ import { useDictStore } from '@/store/modules/dict'
import DictTag from '@/components/DictTag/index.vue'
import { $modal } from '@/utils/modal'
import { useContractPerm } from '@/hooks/business/useContractPerm'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
const router = useRouter()
const dictStore = useDictStore()
@@ -455,6 +456,16 @@ const submitForm = () => {
/** 删除按钮操作 */
const handleDelete = async (row?: ChangeVO) => {
const targets = row ? [row] : changeList.value.filter((item) => ids.value.includes(item.id))
const deletable = filterDeletableByCreator(targets)
if (!deletable.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < targets.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
const idList = row?.id ? [row.id] : ids.value;
await $modal.confirm('是否确认删除选中的变更记录?')
await delChange(idList)
@@ -37,7 +37,11 @@
>
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button v-if="hasContractPerm('remove')" class="btn-ghost" @click="handleDetailDelete">
<el-button
v-if="hasContractPerm('remove') && canDeleteByCreator(detailContract)"
class="btn-ghost"
@click="handleDetailDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
@@ -161,19 +165,37 @@
</template>
</el-table-column>
<el-table-column label="数量" prop="quantity" width="110" align="right" />
<el-table-column label="含税单价(元)" prop="price" width="130" align="right">
<el-table-column
v-if="!hideProductPrice"
label="含税单价(元)"
prop="price"
width="130"
align="right"
>
<template #default="scope">
<span v-if="detailContract.contractType === 'framework'" class="text-muted">随行就市</span>
<span v-else class="money-display">¥{{ parseFloat(scope.row.price || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="含税总价(元)" prop="subtotal" width="130" align="right">
<el-table-column
v-if="!hideProductPrice"
label="含税总价(元)"
prop="subtotal"
width="130"
align="right"
>
<template #default="scope">
<span v-if="detailContract.contractType === 'framework'" class="text-muted">随行就市</span>
<span v-else class="money-display">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="税费(元)" prop="taxAmount" width="120" align="right">
<el-table-column
v-if="!hideProductPrice"
label="税费(元)"
prop="taxAmount"
width="120"
align="right"
>
<template #default="scope">
<span v-if="detailContract.contractType === 'framework'" class="text-muted">随行就市</span>
<span v-else class="money-display">¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
@@ -198,7 +220,7 @@
</div>
</div>
<template v-if="detailContract.contractType === 'framework'">
<template v-if="detailContract.contractType === 'framework' && !hideProductPrice">
<el-divider content-position="left">框架合同调价明细</el-divider>
<p class="text-muted" style="font-size: 13px; margin-bottom: 12px">按生效区间记录含税单价及调价函;后续结算可据此核对。</p>
<div
@@ -247,6 +269,7 @@
</template>
<!-- 合计金额 -->
<template v-if="!hideProductPrice">
<el-divider />
<div class="total-section">
<el-row :gutter="20">
@@ -320,6 +343,7 @@
</el-card>
</el-col>
</el-row>
</template>
</div>
<el-empty v-else description="加载中..." />
</el-tab-pane>
@@ -573,7 +597,7 @@
<el-descriptions-item label="完成说明" :span="2">
<div class="remark-content">{{ detailContract?.remark || '-' }}</div>
</el-descriptions-item>
<el-descriptions-item label="执行情况汇总" :span="2">
<el-descriptions-item v-if="!hideProductPrice" label="执行情况汇总" :span="2">
<el-row :gutter="20" class="mt-2">
<el-col :span="8">
<el-statistic title="合同金额(元)" :value="detailContract?.amount || 0" :precision="2" class="money-statistic">
@@ -794,6 +818,7 @@ import DictTag from '@/components/DictTag/index.vue';
import { $modal } from '@/utils/modal';
import { parseTime } from '@/utils/format';
import { useContractPerm } from '@/hooks/business/useContractPerm';
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete';
import ApprovalDialog from './ApprovalDialog.vue';
import ExecuteDialog from './ExecuteDialog.vue';
import InvalidDialog from './InvalidDialog.vue';
@@ -824,6 +849,15 @@ import {
const dictStore = useDictStore();
const router = useRouter();
const props = withDefaults(
defineProps<{
hideProductPrice?: boolean
}>(),
{
hideProductPrice: false
}
)
const visible = ref(false);
const detailLoading = ref(false);
const detailActiveTab = ref('contract');
@@ -1330,7 +1364,9 @@ const detailBasicViewFields = computed<ViewField[]>(() => {
long: false
},
{ label: '负责人', value: c.ownerUserName },
{ label: '合同金额(元)', value: `¥${formatDetailAmount(c.amount)}`, money: true },
...(props.hideProductPrice
? []
: [{ label: '合同金额(元)', value: `¥${formatDetailAmount(c.amount)}`, money: true }]),
{ label: '交易主体', value: c.supplierName },
detailTagField('结算方式', 'crm_settlement_method', c.settlementMethod)
];
@@ -40,15 +40,6 @@
</template>
</el-input>
</el-form-item>
<el-form-item label="关联商机" prop="opportunityId">
<el-input v-model="form.opportunityName" placeholder="请选择商机(可选)" readonly clearable>
<template #append>
<el-button text @click="handleSelectOpportunity">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="关联报价单" prop="quoteId">
<el-input v-model="form.quoteNo" placeholder="请选择报价单(可选)" readonly clearable>
<template #append>
@@ -501,14 +492,6 @@
@confirm="handleContactConfirm"
/>
<!-- 商机选择器 -->
<OpportunitySelector
:visible="opportunitySelectorVisible"
@update:visible="opportunitySelectorVisible = $event"
:customerId="form.customerId"
@confirm="handleOpportunityConfirm"
/>
<!-- 报价单选择器 -->
<QuoteSelector
:visible="quoteSelectorVisible"
@@ -563,7 +546,6 @@ import { getQuote } from '@/api/crm/quote'
import ProductSelector from './ProductSelector.vue'
import CustomerSelector from '@/components/CustomerSelector.vue'
import ContactSelector from '@/components/ContactSelector.vue'
import OpportunitySelector from '@/components/OpportunitySelector.vue'
import QuoteSelector from '@/components/QuoteSelector.vue'
import ApprovalDialog from './ApprovalDialog.vue'
import { getContactByCustomerId } from '@/api/crm/customer'
+18 -42
View File
@@ -28,7 +28,12 @@
@click="handleEdit"
v-if="hasContractPerm('edit') && contract?.status === 'draft'"
>修改</el-button>
<el-button type="danger" icon="Delete" @click="handleDelete" v-if="hasContractPerm('remove')">删除</el-button>
<el-button
v-if="hasContractPerm('remove') && canDeleteByCreator(contract)"
type="danger"
icon="Delete"
@click="handleDelete"
>删除</el-button>
<el-button
type="warning"
icon="Promotion"
@@ -1021,6 +1026,7 @@ import {
downloadCrmAttachment
} from '@/utils/crm-attachment';
import { useContractPerm } from '@/hooks/business/useContractPerm';
import { canDeleteByCreator } from '@/utils/creator-delete';
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
@@ -1225,47 +1231,17 @@ const getOperLogList = async (reload: boolean = false) => {
if (!contract.value?.id) return;
logLoading.value = true;
try {
// 如果需要重新加载或者还没有加载过完整数据,则从服务器获取
if (reload || operLogListFull.value.length === 0) {
// 获取所有操作日志,不限制模块
const query = {
pageNum: 1,
pageSize: 100 // 获取更多记录用于筛选
};
const res = await listOperLog(query);
const payload = res as { rows?: OperLogVO[]; list?: OperLogVO[] } | OperLogVO[];
const rows = Array.isArray(payload) ? payload : payload?.rows || payload?.list || [];
// 过滤出与当前合同相关的日志
const contractIdStr = String(contract.value.id);
operLogListFull.value = rows.filter((log: OperLogVO) => {
// 检查URL是否包含合同ID
const urlMatch = log.operUrl && log.operUrl.includes(`/crm/contract`) && log.operUrl.includes(`/${contractIdStr}`);
// 检查参数中是否包含合同ID(支持多种格式)
const paramMatch =
log.operParam &&
(log.operParam.includes(`"id":${contractIdStr}`) ||
log.operParam.includes(`"id":"${contractIdStr}"`) ||
log.operParam.includes(`"contractId":${contractIdStr}`) ||
log.operParam.includes(`"contractId":"${contractIdStr}"`) ||
log.operParam.includes(`[${contractIdStr}]`) ||
log.operParam.includes(`["${contractIdStr}"]`));
return urlMatch || paramMatch;
});
// 按时间倒序排序
operLogListFull.value.sort((a, b) => {
return new Date(b.operTime).getTime() - new Date(a.operTime).getTime();
});
}
// 从完整列表中进行分页
const start = (logQuery.value.pageNum - 1) * logQuery.value.pageSize;
const end = start + logQuery.value.pageSize;
logTotal.value = operLogListFull.value.length;
operLogList.value = operLogListFull.value.slice(start, end);
const query = {
pageNum: logQuery.value.pageNum,
pageSize: logQuery.value.pageSize,
businessId: String(contract.value.id),
businessTitle: 'CRM合同管理'
};
const res = await listOperLog(query);
const payload = res as { rows?: OperLogVO[]; list?: OperLogVO[]; total?: number };
operLogListFull.value = payload?.rows || payload?.list || [];
logTotal.value = payload?.total ?? operLogListFull.value.length;
operLogList.value = operLogListFull.value;
} catch (error) {
console.error('获取操作日志失败:', error);
operLogList.value = [];
+1 -19
View File
@@ -40,7 +40,7 @@
</el-col>
</el-row>
<!-- 第一行:客户名称、关联商机、关联报价单、联系人 -->
<!-- 第一行:客户名称、关联报价单、联系人 -->
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="客户名称" prop="customerId" required>
@@ -51,15 +51,6 @@
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="关联商机" prop="opportunityId">
<el-input v-model="form.opportunityName" placeholder="请选择商机(可选)" readonly clearable>
<template #append>
<el-button text icon="Search" @click="handleSelectOpportunity" />
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="关联报价单" prop="quoteId">
<el-input v-model="form.quoteNo" placeholder="请选择报价单(可选)" readonly clearable>
@@ -560,14 +551,6 @@
@confirm="handleContactConfirm"
/>
<!-- 商机选择器 -->
<OpportunitySelector
:visible="opportunitySelectorVisible"
@update:visible="opportunitySelectorVisible = $event"
:customerId="form.customerId"
@confirm="handleOpportunityConfirm"
/>
<!-- 报价单选择器 -->
<QuoteSelector
:visible="quoteSelectorVisible"
@@ -612,7 +595,6 @@ import { getQuote } from '@/api/crm/quote';
import ProductSelector from './components/ProductSelector.vue';
import CustomerSelector from '@/components/CustomerSelector.vue';
import ContactSelector from '@/components/ContactSelector.vue';
import OpportunitySelector from '@/components/OpportunitySelector.vue';
import QuoteSelector from '@/components/QuoteSelector.vue';
import UserSelector from '@/components/UserSelector.vue';
import ApprovalDialog from './components/ApprovalDialog.vue';
@@ -571,6 +571,7 @@ import { $modal } from '@/utils/modal'
import { parseTime } from '@/utils/format'
import { useUserStore } from '@/store/modules/user'
import { useContractPerm } from '@/hooks/business/useContractPerm'
import { filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import ApprovalDialog from './components/ApprovalDialog.vue'
import ExecuteDialog from './components/ExecuteDialog.vue'
import InvalidDialog from './components/InvalidDialog.vue'
@@ -1175,6 +1176,15 @@ const handleDelete = async (row?: ContractVO) => {
$modal.msgWarning('只能删除草稿状态的合同');
return;
}
const deletable = filterDeletableByCreator(selectedContracts)
if (!deletable.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < selectedContracts.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
await $modal.confirm('是否确认删除选中的合同?只能删除草稿状态的合同');
await delContract(idList);
$modal.msgSuccess('删除成功');
@@ -45,7 +45,7 @@
<el-button type="primary" icon="Plus" @click="handleAdd">新增</el-button>
</el-col>
<el-col v-if="hasContractPerm('templateRemove')" :span="1.5">
<el-button type="danger" icon="Delete" :disabled="multiple" @click="handleDelete">删除</el-button>
<el-button type="danger" icon="Delete" :disabled="multiple || !allRowsDeletableByCreator(templateList.filter((item) => ids.includes(item.id)))" :title="allRowsDeletableByCreator(templateList.filter((item) => ids.includes(item.id))) ? '' : CREATOR_DELETE_TIP" @click="handleDelete">删除</el-button>
</el-col>
<el-col v-if="hasContractPerm('templateExport')" :span="1.5">
<el-button type="success" icon="Download" @click="handleExport">导出</el-button>
@@ -93,7 +93,7 @@
<el-tooltip v-if="hasContractPerm('templateEdit')" content="编辑" placement="top">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" />
</el-tooltip>
<el-tooltip v-if="hasContractPerm('templateRemove')" content="删除" placement="top">
<el-tooltip v-if="hasContractPerm('templateRemove') && canDeleteByCreator(scope.row)" content="删除" placement="top">
<el-button link type="danger" icon="Delete" @click="handleDelete(scope.row)" />
</el-tooltip>
</template>
@@ -203,6 +203,7 @@ import { useDictStore } from '@/store/modules/dict'
import DictTag from '@/components/DictTag/index.vue'
import { $modal } from '@/utils/modal'
import { useContractPerm } from '@/hooks/business/useContractPerm'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
const dictStore = useDictStore()
const { loadPerms, hasContractPerm } = useContractPerm()
@@ -365,6 +366,16 @@ const submitForm = () => {
/** 删除按钮操作 */
const handleDelete = async (row?: TemplateVO) => {
const targets = row ? [row] : templateList.value.filter((item) => ids.value.includes(item.id))
const deletable = filterDeletableByCreator(targets)
if (!deletable.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < targets.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
const idList = row?.id ? [row.id] : ids.value;
await $modal.confirm('是否确认删除选中的合同模板?')
await delTemplate(idList)
@@ -36,7 +36,13 @@
<el-button class="btn-main" type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-ghost" type="danger" @click="handleDelete">
<el-button
class="btn-ghost"
type="danger"
:disabled="!canDeleteByCreator(customerInfo)"
:title="canDeleteByCreator(customerInfo) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
@@ -73,14 +79,11 @@
}}</span>
</div>
<el-button
v-if="
item.ownerUserName === userStore.info.realName ||
item.createByName === userStore.info.realName ||
userStore.info.roles?.includes('admin')
"
v-if="canDeleteByCreator(item)"
link
type="danger"
size="small"
:title="canDeleteByCreator(item) ? '' : CREATOR_DELETE_TIP"
@click="handleDeleteFollow(item)"
>
<el-icon><Delete /></el-icon>删除
@@ -199,7 +202,13 @@
<el-button link type="primary" @click="handleEditContact(row)">
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button link type="danger" @click="handleDeleteContact(row)">
<el-button
link
type="danger"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDeleteContact(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
@@ -487,6 +496,7 @@ import { list as listOperLog } from '@/api/monitor/operlog'
import type { OperLogVO } from '@/api/monitor/operlog/types'
import { useDictStore } from '@/store/modules/dict'
import { useUserStore } from '@/store/modules/user'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import CustomerForm from './CustomerFormDialog.vue'
import FollowForm from './FollowFormDialog.vue'
import ContactForm from './components/ContactFormDialog.vue'
@@ -751,39 +761,16 @@ async function getOperLogList(reload = false) {
if (!customerId.value) return
logLoading.value = true
try {
if (reload || !operLogListFull.value.length) {
const res = await listOperLog({ pageNum: 1, pageSize: 100 })
const payload = res as { rows?: OperLogVO[]; list?: OperLogVO[] } | OperLogVO[]
const rows = Array.isArray(payload)
? payload
: payload?.rows || payload?.list || []
const customerIdStr = String(customerId.value)
const matchCustomerId = (text?: string) => {
if (!text) return false
return (
text.includes(`"id":${customerIdStr}`) ||
text.includes(`"id":"${customerIdStr}"`) ||
text.includes(`"customerId":${customerIdStr}`) ||
text.includes(`"customerId":"${customerIdStr}"`) ||
text.includes(`[${customerIdStr}]`) ||
text.includes(`["${customerIdStr}"]`) ||
text.includes(`/${customerIdStr}`)
)
}
operLogListFull.value = rows.filter((log: OperLogVO) => {
const urlMatch =
log.operUrl?.includes('/crm/customer') && matchCustomerId(log.operUrl)
const paramMatch = matchCustomerId(log.operParam)
const resultMatch = matchCustomerId(log.jsonResult)
return urlMatch || paramMatch || resultMatch
})
operLogListFull.value.sort(
(a, b) => new Date(b.operTime || 0).getTime() - new Date(a.operTime || 0).getTime()
)
}
const start = (logQuery.value.pageNum - 1) * logQuery.value.pageSize
logTotal.value = operLogListFull.value.length
operLogList.value = operLogListFull.value.slice(start, start + logQuery.value.pageSize)
const res = await listOperLog({
pageNum: logQuery.value.pageNum,
pageSize: logQuery.value.pageSize,
businessId: String(customerId.value),
businessTitle: 'CRM客户管理'
})
const payload = res as { rows?: OperLogVO[]; list?: OperLogVO[]; total?: number }
operLogListFull.value = payload?.rows || payload?.list || []
logTotal.value = payload?.total ?? operLogListFull.value.length
operLogList.value = operLogListFull.value
} catch (error) {
console.error(error)
operLogList.value = []
@@ -75,7 +75,13 @@
<el-button v-if="row.isPrimary !== 1" link type="warning" @click="handleSetPrimary(row)">
<el-icon><Star /></el-icon>设为主要
</el-button>
<el-button link type="danger" @click="handleDelete(row)">
<el-button
link
type="danger"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
@@ -90,6 +96,7 @@
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useDictStore } from '@/store/modules/dict'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
fetchContactsByCustomerId,
fetchDeleteContact,
@@ -99,7 +99,13 @@
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click="handleDelete(row)">
<el-button
link
type="danger"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
@@ -162,6 +168,7 @@ import { ref, watch } from 'vue'
import { Plus, Edit, Delete, Star, Close, Check } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/http'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
interface InvoiceInfoVO {
id: string | number
+17 -1
View File
@@ -607,7 +607,12 @@
<el-button class="btn-op-item" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-op-item btn-op-danger" @click="handleDelete(row)">
<el-button
class="btn-op-item btn-op-danger"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
@@ -703,6 +708,7 @@ import {
Edit,
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { canDeleteByCreator, filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
fetchCustomerList,
fetchCustomerDetail,
@@ -983,6 +989,16 @@ async function handleDelete(row?: any) {
ElMessage.warning('请至少选择一条数据进行删除')
return
}
const targets = row ? [row] : selectedRows.value
const deletable = filterDeletableByCreator(targets)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < targets.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('是否确认删除选中的客户数据?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
@@ -177,6 +177,7 @@
<el-button
class="btn-op-item danger"
v-auth="'delete'"
v-if="canDeleteByCreator(row)"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>
@@ -224,6 +225,7 @@ import {
fetchExecutePoolRules,
} from '@/api/crm/pool'
import RuleDialog from './components/RuleDialog.vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
// 搜索表单
const searchForm = reactive({
@@ -33,7 +33,8 @@
v-if="hasOpportunityPerm('remove')"
class="btn-ghost"
type="danger"
:disabled="opportunityInfo.status === 'won'"
:disabled="opportunityInfo.status === 'won' || !canDeleteByCreator(opportunityInfo)"
:title="canDeleteByCreator(opportunityInfo) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>删除
@@ -79,7 +80,7 @@
</el-tag>
<span class="follow-creator">{{ item.followByName || item.createByName || '—' }}</span>
</div>
<div v-if="String(item.followBy) === String(currentUserId)" class="follow-actions">
<div v-if="canDeleteByCreator(item)" class="follow-actions">
<el-button link type="primary" size="small" @click="handleEditFollow(item)">
<el-icon><Edit /></el-icon>
</el-button>
@@ -190,6 +191,7 @@ import AttachmentUploadField from '@/components/business/attachment-upload-field
import { useDictStore } from '@/store/modules/dict'
import { useUserStore } from '@/store/modules/user'
import { useOpportunityPerm } from '@/hooks/business/useOpportunityPerm'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
getOpportunity,
listFollowByOpportunity,
@@ -459,6 +459,7 @@ import {
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { useOpportunityPerm } from '@/hooks/business/useOpportunityPerm'
import { filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { fetchUserOptions } from '@/api/admin'
import { listOpportunity, listMyOpportunity, delOpportunity, assignOpportunity, exportOpportunity } from '@/api/crm/opportunity'
import type { OpportunityQuery, OpportunityVO } from '@/api/crm/opportunity/types'
@@ -777,6 +778,16 @@ const handleDelete = async (row?: OpportunityVO) => {
ElMessage.warning('已赢单的商机不允许删除');
return;
}
const targets = row ? [row] : opportunityList.value.filter((item) => ids.value.includes(item.id))
const deletable = filterDeletableByCreator(targets)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < targets.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
const idList = row?.id ? [row.id] : ids.value;
await ElMessageBox.confirm('是否确认删除选中的商机数据?删除后将无法恢复!', '提示', {
type: 'warning'
@@ -75,7 +75,8 @@
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedId"
:disabled="!selectedId || !canDeleteSelectedCategory"
:title="canDeleteSelectedCategory ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>
@@ -219,7 +220,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
@@ -232,6 +233,7 @@ import {
RefreshLeft,
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
fetchCategoryTree,
fetchCreateCategory,
@@ -249,6 +251,12 @@ const refreshTable = ref(true)
const selectedId = ref<string>()
const selectedRows = ref<any[]>([])
const canDeleteSelectedCategory = computed(() => {
if (!selectedId.value) return false
const node = findNodeById(categoryList.value, selectedId.value)
return canDeleteByCreator(node)
})
// ── 弹窗数据 ──
const dialogVisible = ref(false)
const dialogTitle = ref('')
@@ -377,6 +385,11 @@ async function handleDelete() {
ElMessage.warning('请选择一条数据进行删除')
return
}
const node = findNodeById(categoryList.value, selectedId.value)
if (!canDeleteByCreator(node)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('是否确认删除该产品分类?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
+12 -1
View File
@@ -225,7 +225,7 @@
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-dropdown-item command="delete" divided :disabled="!canDeleteByCreator(row)">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -490,6 +490,7 @@ import SystemViewDialog, {
import { crmDictField, crmField, crmLongField, crmTagField } from '@/utils/crm/view-fields'
import { useDictStore } from '@/store/modules/dict'
import { formatMoneyDisplay } from '@/utils/money-format'
import { canDeleteByCreator, filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import {
fetchCategoryTree,
@@ -735,6 +736,16 @@ async function handleDelete(row?: any) {
ElMessage.warning('请至少选择一条数据进行删除')
return
}
const targets = row ? [row] : selectedRows.value
const deletable = filterDeletableByCreator(targets)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < targets.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('是否确认删除选中的产品?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
@@ -53,9 +53,10 @@
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button
v-if="quoteInfo.status !== 'invalid'"
v-if="quoteInfo.status !== 'invalid' && canDeleteByCreator(quoteInfo)"
class="btn-ghost"
type="danger"
:title="canDeleteByCreator(quoteInfo) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>删除
@@ -312,6 +313,7 @@ const CustomerDetailDialog = defineAsyncComponent(
)
import { useDictStore } from '@/store/modules/dict'
import { useQuotePerm } from '@/hooks/business/useQuotePerm'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { $modal } from '@/utils/modal'
import { parseTime } from '@/utils/format'
import {
+7 -1
View File
@@ -16,7 +16,12 @@
<el-button v-if="quote?.status === 'draft'" link type="primary" @click="handleSubmit">
<el-icon><CircleCheck /></el-icon>提交审批
</el-button>
<el-button link type="danger" @click="handleDelete">
<el-button
v-if="quote?.status === 'draft' && canDeleteByCreator(quote)"
link
type="danger"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
@@ -384,6 +389,7 @@ const dictStore = useDictStore()
import DictTag from '@/components/DictTag/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { $modal } from '@/utils/modal'
import { canDeleteByCreator } from '@/utils/creator-delete'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Back, CircleCheck, Close, Delete, Document, Download, Edit, Printer } from '@element-plus/icons-vue'
+11
View File
@@ -816,6 +816,7 @@ import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { useQuotePerm } from '@/hooks/business/useQuotePerm'
import { filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import QuoteFormDialog from './components/QuoteFormDialog.vue'
import QuoteDetailDialog from './components/QuoteDetailDialog.vue'
@@ -1425,6 +1426,16 @@ const handleDelete = async (row?: QuoteVO) => {
$modal.msgWarning('只能删除草稿状态的报价单');
return;
}
const targets = row ? [row] : quoteList.value.filter((item) => ids.value.includes(item.id))
const deletable = filterDeletableByCreator(targets)
if (!deletable.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < targets.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
const idList = row?.id ? [row.id] : ids.value;
await $modal.confirm('是否确认删除选中的报价单?只能删除草稿状态的报价单');
await delQuote(idList);
+7 -2
View File
@@ -371,8 +371,8 @@
</el-button>
<el-button
class="btn-op-item danger"
:disabled="!canDeleteRecord(row)"
:title="canDeleteRecord(row) ? '' : '仅支持删除当天及之后日期的记录'"
:disabled="!canDeleteRecord(row) || !canDeleteByCreator(row)"
:title="!canDeleteRecord(row) ? '仅支持删除当天及之后日期的记录' : canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>
@@ -514,6 +514,7 @@
View
} from '@element-plus/icons-vue'
import DbCrmCustomerSelect from '@/components/business/db/DbCrmCustomerSelect.vue'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import DbCrmProductSelect from '@/components/business/db/DbCrmProductSelect.vue'
import DbInventoryRelatedViewDialog from '@/components/business/db/DbInventoryRelatedViewDialog.vue'
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
@@ -884,6 +885,10 @@
$modal.msgWarning('仅支持删除当天及之后日期的记录,历史数据不可删除')
return
}
if (!canDeleteByCreator(row)) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
try {
await $modal.confirm('确定要删除这条记录吗?')
await inventoryApi.delete(row.id)
+13 -3
View File
@@ -287,7 +287,12 @@
<el-icon><DocumentAdd /></el-icon>
生成出入库单
</el-button>
<el-button class="btn-op-item danger" @click="handleDelete(row.id)">
<el-button
class="btn-op-item danger"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
@@ -459,6 +464,7 @@
import DbPlanRelatedViewDialog from '@/components/business/db/DbPlanRelatedViewDialog.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { $modal } from '@/utils/modal'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
planApi,
type Plan,
@@ -769,10 +775,14 @@
}
}
const handleDelete = async (id: string) => {
const handleDelete = async (row: Plan) => {
if (!canDeleteByCreator(row)) {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
try {
await $modal.confirm('确定要删除这个计划吗?')
await planApi.delete(id)
await planApi.delete(row.id)
$modal.msgSuccess('删除成功')
loadData()
} catch {
+227 -13
View File
@@ -24,6 +24,13 @@
<el-option label="已终止" value="0" />
</el-select>
</el-form-item>
<el-form-item label="审核状态">
<el-select v-model="searchForm.auditStatus" placeholder="全部" clearable>
<el-option label="待审核" value="pending" />
<el-option label="已通过" value="approved" />
<el-option label="已驳回" value="rejected" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-end">
@@ -73,6 +80,18 @@
<el-tag :type="row.status === '1' ? 'success' : 'danger'" size="small">
{{ row.status === '1' ? '合作中' : '已终止' }}
</el-tag>
<el-tag
:type="getPlCarrierAuditStatusTagType(row.auditStatus)"
size="small"
>
{{ getPlCarrierAuditStatusLabel(row.auditStatus) }}
</el-tag>
<el-tag v-if="row.transportAgreementExpired" type="danger" size="small">
协议已过期
</el-tag>
<el-tag v-else-if="row.qualificationComplete === false" type="warning" size="small">
资质不全
</el-tag>
</div>
</div>
@@ -100,7 +119,16 @@
<el-button link type="primary" @click.stop="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click.stop="handleDelete(row)">
<el-button
v-if="canApproveCarrier(row)"
link
type="primary"
:loading="auditLoadingMap[row.id]"
@click.stop="handleApprove(row)"
>
<el-icon><Select /></el-icon>审核通过
</el-button>
<el-button link type="danger" :disabled="!canDeleteByCreator(row)" :title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP" @click.stop="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
@@ -113,7 +141,7 @@
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="520px"
width="720px"
align-center
class="dialog-form-layout"
@close="handleDialogClose"
@@ -145,6 +173,52 @@
maxlength="512"
/>
</el-form-item>
<el-divider content-position="left">资质附件</el-divider>
<div class="form-grid-2">
<el-form-item label="营业执照" prop="businessLicenseUrls">
<AttachmentUploadField
ref="businessLicenseUploadRef"
v-model="formData.businessLicenseUrls"
:limit="5"
accept="image/*,.pdf"
folder="pl/carrier/license"
hint="支持 JPG、PNG、PDF,单文件不超过 20MB,最多 5 个"
@update:model-value="handleCertFieldChange('businessLicenseUrls')"
/>
</el-form-item>
<el-form-item label="道路经营许可证" prop="roadTransportLicenseUrls">
<AttachmentUploadField
ref="roadTransportLicenseUploadRef"
v-model="formData.roadTransportLicenseUrls"
:limit="5"
accept="image/*,.pdf"
folder="pl/carrier/permit"
hint="支持 JPG、PNG、PDF,单文件不超过 20MB,最多 5 个"
@update:model-value="handleCertFieldChange('roadTransportLicenseUrls')"
/>
</el-form-item>
<el-form-item label="运输协议" prop="transportAgreementUrls" class="form-col-span-2">
<AttachmentUploadField
ref="transportAgreementUploadRef"
v-model="formData.transportAgreementUrls"
:limit="5"
accept="image/*,.pdf"
folder="pl/carrier/agreement"
hint="支持 JPG、PNG、PDF,单文件不超过 20MB,最多 5 个"
@update:model-value="handleCertFieldChange('transportAgreementUrls')"
/>
</el-form-item>
<el-form-item label="协议有效期" prop="transportAgreementExpireDate">
<el-date-picker
v-model="formData.transportAgreementExpireDate"
type="date"
placeholder="请选择协议有效期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
@@ -163,25 +237,36 @@
</template>
<script setup lang="ts">
import { Check, Close, Delete, Edit, Plus, RefreshLeft, Search, View } from '@element-plus/icons-vue'
import { Check, Close, Delete, Edit, Plus, RefreshLeft, Search, Select, View } from '@element-plus/icons-vue'
import { carrierApi, type Carrier } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlCarrierViewDialog from '@/components/business/pl-carrier-view-dialog/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
getPlCarrierAuditStatusLabel,
getPlCarrierAuditStatusTagType
} from '@/utils/pl-display'
const loading = ref(false)
const submitLoading = ref(false)
const auditLoadingMap = reactive<Record<string, boolean>>({})
const carrierList = ref<Carrier[]>([])
const selectedRows = ref<Carrier[]>([])
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formRef = ref()
const businessLicenseUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const roadTransportLicenseUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const transportAgreementUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const isEdit = ref(false)
const viewVisible = ref(false)
const viewCarrierId = ref('')
const searchForm = reactive({
q: '',
status: ''
status: '',
auditStatus: ''
})
const formData = reactive({
@@ -190,11 +275,70 @@ const formData = reactive({
contactName: '',
contactPhone: '',
status: '1',
remark: ''
remark: '',
businessLicenseUrls: '',
roadTransportLicenseUrls: '',
transportAgreementUrls: '',
transportAgreementExpireDate: ''
})
const formRules = {
carrierName: [{ required: true, message: '请输入承运商名称', trigger: 'blur' }]
const createQualificationRules = {
businessLicenseUrls: [
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value || !String(value).trim()) {
callback(new Error('请上传营业执照'))
} else {
callback()
}
},
trigger: 'change'
}
],
roadTransportLicenseUrls: [
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value || !String(value).trim()) {
callback(new Error('请上传道路经营许可证'))
} else {
callback()
}
},
trigger: 'change'
}
],
transportAgreementUrls: [
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value || !String(value).trim()) {
callback(new Error('请上传运输协议'))
} else {
callback()
}
},
trigger: 'change'
}
],
transportAgreementExpireDate: [
{ required: true, message: '请选择协议有效期', trigger: 'change' }
]
}
const formRules = computed(() => ({
carrierName: [{ required: true, message: '请输入承运商名称', trigger: 'blur' }],
...(isEdit.value ? {} : createQualificationRules)
}))
function handleCertFieldChange(
field: 'businessLicenseUrls' | 'roadTransportLicenseUrls' | 'transportAgreementUrls'
) {
formRef.value?.validateField(field)
}
function hasAttachmentUploading() {
return [businessLicenseUploadRef, roadTransportLicenseUploadRef, transportAgreementUploadRef].some(
item => item.value?.hasUploading?.()
)
}
async function fetchData() {
@@ -202,7 +346,8 @@ async function fetchData() {
try {
const res = await carrierApi.list({
q: searchForm.q || undefined,
status: searchForm.status || undefined
status: searchForm.status || undefined,
auditStatus: searchForm.auditStatus || undefined
})
carrierList.value = (res.data ?? res) || []
const idSet = new Set(carrierList.value.map(item => item.id))
@@ -219,10 +364,35 @@ function handleSearch() {
function handleReset() {
searchForm.q = ''
searchForm.status = ''
searchForm.auditStatus = ''
selectedRows.value = []
fetchData()
}
function canApproveCarrier(row: Carrier) {
return row.auditStatus !== 'approved' && row.qualificationComplete
}
async function handleApprove(row: Carrier) {
if (!canApproveCarrier(row)) {
ElMessage.warning('承运商资质不完整,无法审核通过')
return
}
await ElMessageBox.confirm(
`确认审核通过承运商「${row.carrierName}」?通过后其车辆可用于运输安排。`,
'审核确认',
{ type: 'info', confirmButtonText: '审核通过', cancelButtonText: '取消' }
)
auditLoadingMap[row.id] = true
try {
await carrierApi.approve(row.id)
ElMessage.success('审核通过')
fetchData()
} finally {
auditLoadingMap[row.id] = false
}
}
function isSelected(row: Carrier) {
return selectedRows.value.some(item => item.id === row.id)
}
@@ -246,7 +416,11 @@ function handleAdd() {
contactName: '',
contactPhone: '',
status: '1',
remark: ''
remark: '',
businessLicenseUrls: '',
roadTransportLicenseUrls: '',
transportAgreementUrls: '',
transportAgreementExpireDate: ''
})
dialogVisible.value = true
}
@@ -260,7 +434,11 @@ function handleEdit(row: Carrier) {
contactName: row.contactName,
contactPhone: row.contactPhone,
status: row.status,
remark: row.remark
remark: row.remark,
businessLicenseUrls: row.businessLicenseUrls || '',
roadTransportLicenseUrls: row.roadTransportLicenseUrls || '',
transportAgreementUrls: row.transportAgreementUrls || '',
transportAgreementExpireDate: row.transportAgreementExpireDate || ''
})
dialogVisible.value = true
}
@@ -273,13 +451,45 @@ function handleView(row: Carrier) {
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
if (hasAttachmentUploading()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
carrierName: formData.carrierName,
contactName: formData.contactName,
contactPhone: formData.contactPhone,
status: formData.status,
remark: formData.remark,
...(isEdit.value
? {
...(formData.businessLicenseUrls
? { businessLicenseUrls: formData.businessLicenseUrls }
: {}),
...(formData.roadTransportLicenseUrls
? { roadTransportLicenseUrls: formData.roadTransportLicenseUrls }
: {}),
...(formData.transportAgreementUrls
? { transportAgreementUrls: formData.transportAgreementUrls }
: {}),
...(formData.transportAgreementExpireDate
? { transportAgreementExpireDate: formData.transportAgreementExpireDate }
: {})
}
: {
businessLicenseUrls: formData.businessLicenseUrls,
roadTransportLicenseUrls: formData.roadTransportLicenseUrls,
transportAgreementUrls: formData.transportAgreementUrls,
transportAgreementExpireDate: formData.transportAgreementExpireDate
})
}
if (isEdit.value) {
await carrierApi.update(formData.id, formData)
await carrierApi.update(formData.id, payload)
} else {
await carrierApi.create(formData)
await carrierApi.create(payload)
}
ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
dialogVisible.value = false
@@ -294,6 +504,10 @@ function handleDialogClose() {
}
async function handleDelete(row: Carrier) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('确定删除该承运商吗?', '提示', { type: 'warning' })
await carrierApi.delete(row.id)
ElMessage.success('删除成功')
@@ -301,7 +515,7 @@ async function handleDelete(row: Carrier) {
}
useDbInstantSearch(
() => ({ q: searchForm.q, status: searchForm.status }),
() => ({ q: searchForm.q, status: searchForm.status, auditStatus: searchForm.auditStatus }),
handleSearch
)
+6 -1
View File
@@ -83,7 +83,7 @@
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click="handleDelete(row)">
<el-button link type="danger" :disabled="!canDeleteByCreator(row)" :title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
@@ -177,6 +177,7 @@
import { Check, Close, Delete, Edit, Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
import { driverApi, type Driver } from '@/api/pl'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
const loading = ref(false)
const submitLoading = ref(false)
@@ -315,6 +316,10 @@ function handleDialogClose() {
}
async function handleDelete(row: Driver) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('确定删除该司机吗?', '提示', { type: 'warning' })
await driverApi.delete(row.id)
ElMessage.success('删除成功')
@@ -117,7 +117,7 @@
<el-icon><Promotion /></el-icon>提交审批
</el-button>
<el-button
v-if="row.settlementStatus === 'pending'"
v-if="row.settlementStatus === 'pending' && canDeleteByCreator(row)"
link
type="danger"
@click="handleDelete(row)"
@@ -165,6 +165,7 @@ import {
View
} from '@element-plus/icons-vue'
import { carrierApi, freightApi, type Carrier, type FreightSettlement } from '@/api/pl'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import PlFreightApprovalDialog from '@/components/business/pl-freight-approval-dialog/index.vue'
import PlFreightSettlementFormDialog from '@/components/business/pl-freight-settlement-form-dialog/index.vue'
import PlFreightSettlementViewDialog from '@/components/business/pl-freight-settlement-view-dialog/index.vue'
@@ -269,6 +270,10 @@ async function handleWithdraw(row: FreightSettlement) {
}
async function handleDelete(row: FreightSettlement) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('确定删除该草稿结算单吗?关联运输单将释放。', '提示', {
type: 'warning'
})
@@ -61,11 +61,6 @@
</span>
</template>
</el-table-column>
<el-table-column prop="stockAmount" label="库存金额" min-width="120" align="right">
<template #default="{ row }">
{{ formatAmount(row.stockAmount) }}
</template>
</el-table-column>
<el-table-column label="预警阈值" min-width="150" align="center">
<template #default="{ row }">
{{ row.lowStockWarning || '-' }} ~ {{ row.highStockWarning || '-' }}
@@ -108,7 +103,6 @@
import { RefreshLeft, Search } from '@element-plus/icons-vue'
import { inventoryApi, type InventoryRecord } from '@/api/pl'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { formatMoneyDisplay } from '@/utils/money-format'
const loading = ref(false)
const inventoryList = ref<InventoryRecord[]>([])
@@ -123,11 +117,6 @@ const pagination = reactive({
total: 0
})
function formatAmount(amount: number) {
if (!amount) return '¥0.00'
return formatMoneyDisplay(amount)
}
async function fetchData() {
loading.value = true
try {
+6 -1
View File
@@ -115,7 +115,7 @@
<el-button link type="primary" @click.stop="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click.stop="handleDelete(row)">
<el-button link type="danger" :disabled="!canDeleteByCreator(row)" :title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP" @click.stop="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
@@ -219,6 +219,7 @@ import SystemViewDialog, {
} from '@/components/business/system-view-dialog/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { formatDateTime } from '@/utils/format'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
type CertListItem = CertRow & { _key?: string }
type CertFormListRef = InstanceType<typeof PlCertFormList>
@@ -489,6 +490,10 @@ function handleDialogClose() {
}
async function handleDelete(row: Personnel) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('确定删除该人员吗?', '提示', { type: 'warning' })
await personnelApi.delete(row.id)
ElMessage.success('删除成功')
@@ -154,11 +154,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="totalAmount" label="计划金额" min-width="120" align="right">
<template #default="{ row }">
{{ formatAmount(row.totalAmount) }}
</template>
</el-table-column>
<el-table-column prop="licensePlate" label="车牌号" min-width="120" align="center" />
<el-table-column prop="driverName" label="司机" min-width="100" align="center" />
<el-table-column prop="followUserName" label="跟进人" min-width="100" align="center" />
@@ -237,7 +232,6 @@ import {
getPlPlanArchiveStatusLabel,
getPlPlanArchiveStatusTagType
} from '@/utils/pl-display'
import { formatMoneyDisplay } from '@/utils/money-format'
const loading = ref(false)
const formDialogVisible = ref(false)
@@ -299,11 +293,6 @@ function getFollowStatusLabel(status: string) {
return map[status] || status
}
function formatAmount(amount: number) {
if (!amount) return '¥0.00'
return formatMoneyDisplay(amount)
}
async function fetchData() {
loading.value = true
try {
@@ -132,11 +132,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="totalAmount" label="计划金额" min-width="120" align="right">
<template #default="{ row }">
{{ formatAmount(row.totalAmount) }}
</template>
</el-table-column>
<el-table-column prop="licensePlate" label="车牌号" min-width="120" align="center" />
<el-table-column prop="driverName" label="司机" min-width="100" align="center" />
<el-table-column prop="followUserName" label="跟进人" min-width="100" align="center" />
@@ -196,7 +191,6 @@ import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/ind
import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { formatMoneyDisplay } from '@/utils/money-format'
const loading = ref(false)
const viewVisible = ref(false)
@@ -254,11 +248,6 @@ function getFollowStatusLabel(status: string) {
return map[status] || status
}
function formatAmount(amount: number) {
if (!amount) return '¥0.00'
return formatMoneyDisplay(amount)
}
async function fetchData() {
loading.value = true
try {
+22 -17
View File
@@ -103,7 +103,8 @@
<el-button
class="btn-ghost"
type="danger"
:disabled="multiple"
:disabled="multiple || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
:loading="deleteLoading"
@click="handleBatchDelete"
>
@@ -161,11 +162,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="totalAmount" label="计划金额" min-width="120" align="right">
<template #default="{ row }">
{{ formatAmount(row.totalAmount) }}
</template>
</el-table-column>
<el-table-column prop="licensePlate" label="车牌号" min-width="120" align="center" />
<el-table-column prop="driverName" label="司机" min-width="100" align="center" />
<el-table-column prop="followUserName" label="跟进人" min-width="100" align="center" />
@@ -201,7 +197,13 @@
>
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button link type="danger" @click="handleDelete(row)">
<el-button
link
type="danger"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
@@ -259,6 +261,7 @@ import {
View
} from '@element-plus/icons-vue'
import { planApi, type Plan } from '@/api/pl'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import PlPlanArrangeDialog from '@/components/business/pl-plan-arrange-dialog/index.vue'
import PlPlanCertUploadDialog from '@/components/business/pl-plan-cert-upload-dialog/index.vue'
import PlPlanFormDialog from '@/components/business/pl-plan-form-dialog/index.vue'
@@ -266,8 +269,6 @@ import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/ind
import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { formatMoneyDisplay } from '@/utils/money-format'
const route = useRoute()
const router = useRouter()
@@ -336,11 +337,6 @@ function getFollowStatusLabel(status: string) {
return map[status] || status
}
function formatAmount(amount: number) {
if (!amount) return '¥0.00'
return formatMoneyDisplay(amount)
}
async function fetchData() {
loading.value = true
try {
@@ -454,6 +450,10 @@ function handleArrange(row: Plan) {
}
async function handleDelete(row: Plan) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm(`确定删除运输计划「${row.planNo}」吗?`, '提示', { type: 'warning' })
deleteLoading.value = true
try {
@@ -467,11 +467,16 @@ async function handleDelete(row: Plan) {
}
async function handleBatchDelete() {
const ids = selectedRows.value.map(r => r.id)
if (!ids.length) {
ElMessage.warning('请至少选择一项')
const deletable = filterDeletableByCreator(selectedRows.value)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < selectedRows.value.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
const ids = deletable.map(r => r.id)
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条运输计划吗?`, '提示', {
type: 'warning'
})
+95 -28
View File
@@ -24,6 +24,16 @@
<el-option label="报废" value="scrapped" />
</el-select>
</el-form-item>
<el-form-item label="承运商">
<el-select v-model="searchForm.carrierId" placeholder="全部" clearable filterable>
<el-option
v-for="c in carrierList"
:key="c.id"
:label="c.carrierName"
:value="c.id"
/>
</el-select>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-end">
@@ -84,6 +94,10 @@
<dt>载重(吨)</dt>
<dd>{{ row.loadCapacity || '—' }}</dd>
</div>
<div class="vehicle-card-kv-item">
<dt>承运商</dt>
<dd>{{ row.carrierName || '—' }}</dd>
</div>
<div class="vehicle-card-kv-item">
<dt>关联人员</dt>
<dd>{{ row.personnelSummary || '—' }}</dd>
@@ -101,7 +115,7 @@
<el-button link type="primary" @click.stop="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click.stop="handleDelete(row)">
<el-button link type="danger" :disabled="!canDeleteByCreator(row)" :title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP" @click.stop="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
@@ -135,6 +149,16 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="承运商" prop="carrierId">
<el-select v-model="formData.carrierId" placeholder="请选择承运商(选填)" clearable filterable>
<el-option
v-for="c in carrierList"
:key="c.id"
:label="c.carrierName"
:value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="车辆状态" prop="vehicleStatus">
<el-select v-model="formData.vehicleStatus" placeholder="请选择">
<el-option label="可用" value="available" />
@@ -216,9 +240,11 @@
</el-table>
</div>
<PlCertFormList
<PlVehicleCertPartTabs
ref="certFormListRef"
v-model="certList"
:license-plate="formData.licensePlate"
:trailer-no="formData.trailerNo"
title="车辆证书"
upload-folder="pl/vehicle-cert"
:cert-name-options="certNameOptions"
@@ -250,6 +276,8 @@
<template #vehicleDetailTabs>
<PlVehicleCertPersonnelTabs
:cert-list="viewDetail?.certList || []"
:license-plate="viewDetail?.licensePlate"
:trailer-no="viewDetail?.trailerNo"
:personnel-list="viewPersonnelList"
/>
</template>
@@ -262,13 +290,15 @@ import { Check, Close, Delete, Edit, Plus, RefreshLeft, Search, Van, View } from
import {
vehicleApi,
personnelApi,
carrierApi,
type Vehicle,
type VehicleDetail,
type VehiclePersonnelRow,
type CertRow,
type Personnel
type Personnel,
type Carrier
} from '@/api/pl'
import PlCertFormList from '@/components/business/pl-cert-form-list/index.vue'
import PlVehicleCertPartTabs from '@/components/business/pl-vehicle-cert-part-tabs/index.vue'
import PlVehicleCertPersonnelTabs from '@/components/business/pl-vehicle-cert-personnel-tabs/index.vue'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import SystemViewDialog, {
@@ -281,15 +311,22 @@ import {
getPlVehicleStatusLabel,
getPlVehicleStatusTagType
} from '@/utils/pl-display'
import {
PL_VEHICLE_CERT_NAME_OPTIONS,
splitVehicleCertsByPart,
validateVehicleRequiredCerts
} from '@/utils/pl-vehicle-cert'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
const VEHICLE_TYPE_SELF = 'self'
type CertListItem = CertRow & { _key?: string }
type CertFormListRef = InstanceType<typeof PlCertFormList>
type CertFormListRef = InstanceType<typeof PlVehicleCertPartTabs>
const loading = ref(false)
const submitLoading = ref(false)
const vehicleList = ref<Vehicle[]>([])
const carrierList = ref<Carrier[]>([])
const personnelOptions = ref<Personnel[]>([])
const selectedRows = ref<Vehicle[]>([])
const dialogVisible = ref(false)
@@ -305,18 +342,12 @@ const viewLoading = ref(false)
const viewDetail = ref<VehicleDetail | null>(null)
const viewPersonnelList = ref<VehiclePersonnelViewRow[]>([])
const certNameOptions = [
'行驶证',
'道路运输证',
'驾驶证',
'押运员证',
'危化品从业资格证',
'保险单'
]
const certNameOptions = PL_VEHICLE_CERT_NAME_OPTIONS
const searchForm = reactive({
q: '',
vehicleStatus: ''
vehicleStatus: '',
carrierId: ''
})
const formData = reactive({
@@ -324,6 +355,8 @@ const formData = reactive({
licensePlate: '',
trailerNo: '',
loadCapacity: null as number | null,
carrierId: '',
carrierName: '',
vehicleStatus: 'available',
remark: ''
})
@@ -343,7 +376,11 @@ function getStatusLabel(status: string) {
const viewHeroMeta = computed(() => {
if (!viewDetail.value) return ''
return [getStatusLabel(viewDetail.value.vehicleStatus), '自有']
return [
getStatusLabel(viewDetail.value.vehicleStatus),
'自有',
viewDetail.value.carrierName || undefined
]
.filter(Boolean)
.join(' · ')
})
@@ -359,6 +396,7 @@ const viewSections = computed<ViewSection[]>(() => {
{ label: '车牌号', value: d.licensePlate },
{ label: '挂车号', value: d.trailerNo || '—' },
{ label: '车辆类型', value: '自有', tagType: 'success' },
{ label: '承运商', value: d.carrierName || '—' },
{ label: '载重(吨)', value: d.loadCapacity ?? '—' },
{
label: '车辆状态',
@@ -506,24 +544,33 @@ function validateSubTables(): boolean {
return false
}
}
for (const cert of certList.value) {
if (!cert.certName?.trim()) {
ElMessage.warning('证书名称不能为空')
return false
}
}
return true
const hasTrailer = Boolean(formData.trailerNo?.trim())
const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList.value)
return validateVehicleRequiredCerts({
headCerts,
trailerCerts,
hasTrailer,
licensePlate: formData.licensePlate,
trailerNo: formData.trailerNo,
onError: message => ElMessage.warning(message)
})
}
async function fetchData() {
loading.value = true
try {
const res = await vehicleApi.list({
vehicleType: VEHICLE_TYPE_SELF,
q: searchForm.q || undefined,
vehicleStatus: searchForm.vehicleStatus || undefined
})
vehicleList.value = (res.data ?? res) || []
const [vehicleRes, carrierRes] = await Promise.all([
vehicleApi.list({
vehicleType: VEHICLE_TYPE_SELF,
q: searchForm.q || undefined,
vehicleStatus: searchForm.vehicleStatus || undefined,
carrierId: searchForm.carrierId || undefined
}),
carrierApi.list()
])
vehicleList.value = (vehicleRes.data ?? vehicleRes) || []
carrierList.value = (carrierRes.data ?? carrierRes) || []
const idSet = new Set(vehicleList.value.map(item => item.id))
selectedRows.value = selectedRows.value.filter(item => idSet.has(item.id))
} finally {
@@ -538,6 +585,7 @@ function handleSearch() {
function handleReset() {
searchForm.q = ''
searchForm.vehicleStatus = ''
searchForm.carrierId = ''
selectedRows.value = []
fetchData()
}
@@ -564,6 +612,8 @@ function handleAdd() {
licensePlate: '',
trailerNo: '',
loadCapacity: null,
carrierId: '',
carrierName: '',
vehicleStatus: 'available',
remark: ''
})
@@ -608,6 +658,8 @@ async function handleEdit(row: Vehicle) {
licensePlate: detail.licensePlate,
trailerNo: detail.trailerNo || '',
loadCapacity: detail.loadCapacity ?? null,
carrierId: detail.carrierId || '',
carrierName: detail.carrierName || '',
vehicleStatus: detail.vehicleStatus,
remark: detail.remark
})
@@ -620,6 +672,7 @@ async function handleEdit(row: Vehicle) {
certList.value = (detail.certList || []).map(c => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
vehiclePart: c.vehiclePart,
certName: c.certName,
certNo: c.certNo,
expireDate: c.expireDate,
@@ -641,12 +694,21 @@ async function handleSubmit() {
return
}
if (formData.carrierId) {
const carrier = carrierList.value.find(c => c.id === formData.carrierId)
formData.carrierName = carrier?.carrierName || ''
} else {
formData.carrierName = ''
}
submitLoading.value = true
try {
const payload = {
licensePlate: formData.licensePlate,
trailerNo: formData.trailerNo || undefined,
vehicleType: VEHICLE_TYPE_SELF,
carrierId: formData.carrierId || undefined,
carrierName: formData.carrierName || undefined,
loadCapacity: formData.loadCapacity,
vehicleStatus: formData.vehicleStatus,
remark: formData.remark,
@@ -661,6 +723,7 @@ async function handleSubmit() {
})),
certList: certList.value.map(c => ({
certId: c.certId,
vehiclePart: c.vehiclePart,
certName: c.certName.trim(),
certNo: c.certNo || undefined,
expireDate: c.expireDate || undefined,
@@ -688,6 +751,10 @@ function handleDialogClose() {
}
async function handleDelete(row: Vehicle) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('确定删除该自有车辆吗?', '提示', { type: 'warning' })
await vehicleApi.delete(row.id)
ElMessage.success('删除成功')
@@ -0,0 +1,612 @@
<!-- 车辆维保申请 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">车辆维保申请</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="申请单号/车牌">
<el-input v-model="searchForm.q" placeholder="搜索申请单号或车牌" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="维保类型">
<el-select
v-model="searchForm.maintenanceType"
placeholder="全部"
clearable
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option
v-for="item in PL_MAINTENANCE_TYPE_PRESETS"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable>
<el-option label="待处理" value="pending" />
<el-option label="已处理" value="processed" />
<el-option label="已作废" value="cancelled" />
</el-select>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增申请
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column prop="applyNo" label="申请单号" min-width="170" show-overflow-tooltip />
<el-table-column prop="licensePlate" label="车牌号" min-width="120" align="center" />
<el-table-column prop="maintenanceType" label="维保类型" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlMaintenanceTypeTagType(row.maintenanceType)" size="small">
{{ getPlMaintenanceTypeLabel(row.maintenanceType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="applyDate" label="申请日期" min-width="120" align="center" />
<el-table-column prop="startDate" label="开始日期" min-width="120" align="center" />
<el-table-column prop="expectedPickupDate" label="预计取车" min-width="120" align="center">
<template #default="{ row }">{{ row.expectedPickupDate || '—' }}</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" />
<el-table-column prop="status" label="状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlMaintenanceApplyStatusTagType(row.status)" size="small">
{{ getPlMaintenanceApplyStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
<el-table-column label="操作" min-width="280" fixed="right" align="center" class-name="col-actions">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<template v-if="row.status === 'pending'">
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="warning" :loading="cancelLoadingMap[row.id]" @click="handleCancel(row)">
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button link type="danger" :loading="deleteLoadingMap[row.id]" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</section>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="720px"
align-center
class="dialog-form-layout order-module-dialog-mask"
destroy-on-close
@closed="resetForm"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item v-if="formData.applyNo" label="申请单号">
<el-input v-model="formData.applyNo" disabled />
</el-form-item>
<el-form-item label="申请人" prop="applicantId">
<el-select
v-model="formData.applicantId"
filterable
placeholder="请选择申请人"
style="width: 100%"
>
<el-option
v-for="user in userOptions"
:key="user.userId"
:label="user.displayName"
:value="user.userId"
/>
</el-select>
</el-form-item>
<el-form-item label="车牌号" prop="licensePlate">
<el-select
v-model="formData.licensePlate"
filterable
placeholder="请选择车牌"
style="width: 100%"
@change="handleLicensePlateChange"
>
<el-option
v-for="item in vehicleOptions"
:key="item.id"
:label="item.licensePlate"
:value="item.licensePlate"
/>
</el-select>
</el-form-item>
<el-form-item label="维保类型" prop="maintenanceType">
<el-select
v-model="formData.maintenanceType"
filterable
allow-create
default-first-option
placeholder="请选择或输入维保类型"
style="width: 100%"
>
<el-option
v-for="item in PL_MAINTENANCE_TYPE_PRESETS"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="申请日期" prop="applyDate">
<el-date-picker v-model="formData.applyDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="开始日期" prop="startDate">
<el-date-picker v-model="formData.startDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="预计取车日期">
<el-date-picker
v-model="formData.expectedPickupDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="可选"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="维保内容" prop="maintenanceContent" class="form-col-span-2">
<el-input
v-model="formData.maintenanceContent"
type="textarea"
:rows="3"
placeholder="请输入维保内容"
/>
</el-form-item>
<el-form-item label="维修店地址" class="form-col-span-2">
<el-input v-model="formData.shopAddress" placeholder="请输入维修店地址" />
</el-form-item>
<el-form-item label="附件" class="form-col-span-2">
<AttachmentUploadField
ref="attachmentUploadRef"
v-model="formData.attachmentUrls"
folder="pl/vehicle-maintenance/apply"
accept="image/*,.pdf"
:limit="5"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="备注" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewDetail?.applyNo || '申请详情'"
:sections="viewSections"
:loading="viewLoading"
width="720px"
modal-class="order-module-dialog-mask"
append-to-body
/>
</div>
</template>
<script setup lang="ts">
import {
Check,
CircleClose,
Close,
Delete,
Edit,
Plus,
RefreshLeft,
Search,
View
} from '@element-plus/icons-vue'
import {
vehicleApi,
vehicleMaintenanceApplyApi,
type Vehicle,
type VehicleMaintenanceApply
} from '@/api/pl'
import { fetchUserOptions } from '@/api/admin'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
getPlMaintenanceApplyStatusLabel,
getPlMaintenanceApplyStatusTagType,
getPlMaintenanceTypeLabel,
getPlMaintenanceTypeTagType,
PL_MAINTENANCE_TYPE_PRESETS
} from '@/utils/pl-display'
const userStore = useUserStore()
const currentUserId = computed(() => {
const info = userStore.info as Record<string, unknown> | undefined
return String(info?.id || info?.userId || '')
})
const userOptions = ref<
Array<{ userId: string; nickName: string; displayName: string }>
>([])
const loading = ref(false)
const submitLoading = ref(false)
const cancelLoadingMap = reactive<Record<string, boolean>>({})
const deleteLoadingMap = reactive<Record<string, boolean>>({})
const tableData = ref<VehicleMaintenanceApply[]>([])
const selectedRows = ref<VehicleMaintenanceApply[]>([])
const vehicleOptions = ref<Vehicle[]>([])
const dateRange = ref<string[]>([])
const searchForm = reactive({
q: '',
maintenanceType: '',
status: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const formRef = ref()
const attachmentUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const formData = reactive({
id: '',
applyNo: '',
applicantId: '',
licensePlate: '',
maintenanceType: '',
applyDate: '',
startDate: '',
expectedPickupDate: '',
maintenanceContent: '',
shopAddress: '',
attachmentUrls: '',
remark: ''
})
const formRules = {
applicantId: [{ required: true, message: '请选择申请人', trigger: 'change' }],
licensePlate: [{ required: true, message: '请选择车牌号', trigger: 'change' }],
maintenanceType: [{ required: true, message: '请选择维保类型', trigger: 'change' }],
applyDate: [{ required: true, message: '请选择申请日期', trigger: 'change' }],
startDate: [{ required: true, message: '请选择开始日期', trigger: 'change' }]
}
const viewVisible = ref(false)
const viewLoading = ref(false)
const viewDetail = ref<VehicleMaintenanceApply | null>(null)
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
if (!d) return []
return [
{
title: '申请信息',
fields: [
{ label: '申请单号', value: d.applyNo },
{ label: '车牌号', value: d.licensePlate },
{
label: '维保类型',
value: getPlMaintenanceTypeLabel(d.maintenanceType),
tagType: getPlMaintenanceTypeTagType(d.maintenanceType)
},
{ label: '申请日期', value: d.applyDate },
{ label: '开始日期', value: d.startDate },
{ label: '预计取车', value: d.expectedPickupDate || '—' },
{ label: '申请人', value: d.applicantName || '—' },
{
label: '状态',
value: getPlMaintenanceApplyStatusLabel(d.status),
tagType: getPlMaintenanceApplyStatusTagType(d.status)
},
{ label: '维保内容', value: d.maintenanceContent || '—', span: 2 },
{ label: '维修店地址', value: d.shopAddress || '—', span: 2 },
{ label: '备注', value: d.remark || '—', span: 2 }
]
}
]
})
async function loadUserOptions() {
try {
const res = await fetchUserOptions()
const data: unknown = (res as { data?: unknown })?.data ?? res ?? []
userOptions.value = (Array.isArray(data) ? data : []).map((u: Record<string, unknown>) => {
const name = String(u.label || u.nickName || '')
const account = String(u.userNo || '')
return {
userId: String(u.value ?? u.id ?? ''),
nickName: name,
displayName: account ? `${name}(${account})` : name
}
})
} catch {
userOptions.value = []
}
}
async function loadVehicleOptions() {
const res = await vehicleApi.list()
vehicleOptions.value = (res.data ?? res) || []
}
async function showMaintenanceReminders(licensePlate: string) {
if (!licensePlate) return
try {
const res = await vehicleMaintenanceApplyApi.reminders(
licensePlate,
isEdit.value ? formData.id : undefined
)
const items = (res.data ?? res) || []
if (!items.length) return
const text = items
.map((item, index) => {
const lines = [
`【${index + 1}】申请单号:${item.applyNo}`,
`维保类型:${item.maintenanceType}`,
`开始日期:${item.startDate}`,
`预计取车:${item.expectedPickupDate || '—'}`,
`维保内容:${item.maintenanceContent || '—'}`,
`维修店地址:${item.shopAddress || '—'}`
]
return lines.join('\n')
})
.join('\n\n')
await ElMessageBox.alert(text, '车辆维保提醒', { confirmButtonText: '知道了' })
} catch {
// ignore reminder fetch errors
}
}
function handleLicensePlateChange(licensePlate: string) {
showMaintenanceReminders(licensePlate)
}
async function fetchData() {
loading.value = true
try {
const res = await vehicleMaintenanceApplyApi.list({
q: searchForm.q || undefined,
maintenanceType: searchForm.maintenanceType || undefined,
status: searchForm.status || undefined,
applyDateFrom: dateRange.value?.[0],
applyDateTo: dateRange.value?.[1],
page: pagination.page,
pageSize: pagination.pageSize
})
const data = res.data ?? res
tableData.value = data.items || []
pagination.total = data.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
searchForm.q = ''
searchForm.maintenanceType = ''
searchForm.status = ''
dateRange.value = []
handleSearch()
}
function handleSelectionChange(rows: VehicleMaintenanceApply[]) {
selectedRows.value = rows
}
function resetForm() {
formData.id = ''
formData.applyNo = ''
formData.applicantId = ''
formData.licensePlate = ''
formData.maintenanceType = ''
formData.applyDate = ''
formData.startDate = ''
formData.expectedPickupDate = ''
formData.maintenanceContent = ''
formData.shopAddress = ''
formData.attachmentUrls = ''
formData.remark = ''
}
async function handleAdd() {
resetForm()
isEdit.value = false
dialogTitle.value = '新增申请'
formData.applyDate = new Date().toISOString().slice(0, 10)
formData.applicantId = currentUserId.value
await loadUserOptions()
dialogVisible.value = true
}
async function handleEdit(row: VehicleMaintenanceApply) {
isEdit.value = true
dialogTitle.value = '编辑申请'
await loadUserOptions()
Object.assign(formData, {
id: row.id,
applyNo: row.applyNo,
applicantId: row.applicantId || '',
licensePlate: row.licensePlate,
maintenanceType: row.maintenanceType,
applyDate: row.applyDate,
startDate: row.startDate,
expectedPickupDate: row.expectedPickupDate || '',
maintenanceContent: row.maintenanceContent || '',
shopAddress: row.shopAddress || '',
attachmentUrls: row.attachmentUrls || '',
remark: row.remark || ''
})
dialogVisible.value = true
}
async function handleView(row: VehicleMaintenanceApply) {
viewVisible.value = true
viewLoading.value = true
try {
const res = await vehicleMaintenanceApplyApi.get(row.id)
viewDetail.value = res.data ?? res
} finally {
viewLoading.value = false
}
}
async function handleSubmit() {
await formRef.value?.validate()
if (attachmentUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
applicantId: formData.applicantId,
licensePlate: formData.licensePlate,
maintenanceType: formData.maintenanceType,
applyDate: formData.applyDate,
startDate: formData.startDate,
expectedPickupDate: formData.expectedPickupDate || undefined,
maintenanceContent: formData.maintenanceContent || undefined,
shopAddress: formData.shopAddress || undefined,
attachmentUrls: formData.attachmentUrls || undefined,
remark: formData.remark || undefined
}
if (isEdit.value) {
await vehicleMaintenanceApplyApi.update(formData.id, payload)
ElMessage.success('更新成功')
} else {
await vehicleMaintenanceApplyApi.create(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} finally {
submitLoading.value = false
}
}
async function handleCancel(row: VehicleMaintenanceApply) {
await ElMessageBox.confirm('确定作废该申请吗?', '提示', { type: 'warning' })
cancelLoadingMap[row.id] = true
try {
await vehicleMaintenanceApplyApi.cancel(row.id)
ElMessage.success('已作废')
fetchData()
} finally {
cancelLoadingMap[row.id] = false
}
}
async function handleDelete(row: VehicleMaintenanceApply) {
await ElMessageBox.confirm('确定删除该申请吗?', '提示', { type: 'warning' })
deleteLoadingMap[row.id] = true
try {
await vehicleMaintenanceApplyApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} finally {
deleteLoadingMap[row.id] = false
}
}
onMounted(async () => {
await Promise.all([loadVehicleOptions(), loadUserOptions()])
fetchData()
})
</script>
@@ -0,0 +1,586 @@
<!-- 车辆维保处理登记 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">车辆维保处理登记</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="申请单号/车牌/处理单号">
<el-input v-model="searchForm.q" placeholder="搜索关键词" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="维保类型">
<el-select
v-model="searchForm.maintenanceType"
placeholder="全部"
clearable
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option
v-for="item in PL_MAINTENANCE_TYPE_PRESETS"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="申请状态">
<el-select v-model="searchForm.applyStatus" placeholder="全部" clearable>
<el-option label="待处理" value="pending" />
<el-option label="已处理" value="processed" />
<el-option label="已作废" value="cancelled" />
</el-select>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="applyDateRange"
type="daterange"
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="处理日期">
<el-date-picker
v-model="processDateRange"
type="daterange"
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">共 <span class="sel-num">{{ pagination.total }}</span> 条申请</div>
<el-button @click="handleSupplement">
<el-icon><Plus /></el-icon>无申请补录
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="tableData" v-loading="loading" stripe row-key="applyId">
<el-table-column prop="applyNo" label="申请单号" min-width="170" show-overflow-tooltip fixed="left" />
<el-table-column prop="applyDate" label="申请日期" min-width="120" align="center" />
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" />
<el-table-column prop="applyStatus" label="申请状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlMaintenanceApplyStatusTagType(row.applyStatus)" size="small">
{{ getPlMaintenanceApplyStatusLabel(row.applyStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="licensePlate" label="车牌号" min-width="110" align="center" />
<el-table-column prop="maintenanceType" label="维保类型" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlMaintenanceTypeTagType(row.maintenanceType)" size="small">
{{ getPlMaintenanceTypeLabel(row.maintenanceType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="startDate" label="开始日期" min-width="120" align="center" />
<el-table-column prop="expectedPickupDate" label="预计取车" min-width="120" align="center">
<template #default="{ row }">{{ row.expectedPickupDate || '—' }}</template>
</el-table-column>
<el-table-column prop="processNo" label="处理单号" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ row.processNo || '—' }}</template>
</el-table-column>
<el-table-column prop="processDate" label="处理日期" min-width="120" align="center">
<template #default="{ row }">{{ row.processDate || '—' }}</template>
</el-table-column>
<el-table-column prop="amount" label="金额(元)" min-width="110" align="right">
<template #default="{ row }">
{{ row.amount != null ? Number(row.amount).toFixed(2) : '—' }}
</template>
</el-table-column>
<el-table-column prop="handlerName" label="处理人" min-width="100" align="center">
<template #default="{ row }">{{ row.handlerName || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="260" fixed="right" align="center" class-name="col-actions">
<template #default="{ row }">
<el-button
v-if="row.applyStatus === 'pending'"
link
type="primary"
@click="handleProcess(row)"
>
<el-icon><Tools /></el-icon>处理
</el-button>
<template v-if="row.applyStatus === 'processed' && row.processId">
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
link
type="danger"
:loading="deleteLoadingMap[row.processId]"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>撤销处理
</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</section>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="720px"
align-center
class="dialog-form-layout order-module-dialog-mask"
destroy-on-close
@closed="resetForm"
>
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="top" scroll-to-error>
<div v-if="dialogMode === 'process' && currentApplyRow" class="apply-summary-block">
<div class="apply-summary-title">关联申请</div>
<div class="apply-summary-grid">
<div><span class="label">申请单号</span>{{ currentApplyRow.applyNo }}</div>
<div><span class="label">申请日期</span>{{ currentApplyRow.applyDate }}</div>
<div><span class="label">车牌号</span>{{ currentApplyRow.licensePlate }}</div>
<div>
<span class="label">维保类型</span>
{{ getPlMaintenanceTypeLabel(currentApplyRow.maintenanceType) }}
</div>
<div><span class="label">申请人</span>{{ currentApplyRow.applicantName || '—' }}</div>
<div><span class="label">开始日期</span>{{ currentApplyRow.startDate }}</div>
<div>
<span class="label">预计取车</span>{{ currentApplyRow.expectedPickupDate || '—' }}
</div>
<div class="apply-summary-span-2">
<span class="label">维保内容</span>{{ currentApplyRow.maintenanceContent || '—' }}
</div>
<div class="apply-summary-span-2">
<span class="label">维修店地址</span>{{ currentApplyRow.shopAddress || '—' }}
</div>
</div>
</div>
<div class="form-grid-2">
<el-form-item label="处理单号">
<el-input :model-value="formData.processNo || '保存后自动生成'" disabled />
</el-form-item>
<el-form-item label="处理人" prop="handlerId">
<el-select
v-model="formData.handlerId"
filterable
placeholder="请选择处理人"
style="width: 100%"
>
<el-option
v-for="user in userOptions"
:key="user.userId"
:label="user.displayName"
:value="user.userId"
/>
</el-select>
</el-form-item>
<template v-if="dialogMode === 'supplement'">
<el-form-item label="车牌号" prop="licensePlate">
<el-select
v-model="formData.licensePlate"
filterable
placeholder="请选择车牌"
style="width: 100%"
@change="handleLicensePlateChange"
>
<el-option
v-for="item in vehicleOptions"
:key="item.id"
:label="item.licensePlate"
:value="item.licensePlate"
/>
</el-select>
</el-form-item>
</template>
<el-form-item label="处理日期" prop="processDate">
<el-date-picker v-model="formData.processDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="金额(元)">
<el-input-number
v-model="formData.amount"
:min="0.01"
:precision="2"
placeholder="可选"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="附件" class="form-col-span-2">
<AttachmentUploadField
ref="attachmentUploadRef"
v-model="formData.attachmentUrls"
folder="pl/vehicle-maintenance/process"
accept="image/*,.pdf"
:limit="5"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="备注" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { Check, Close, Delete, Edit, Plus, RefreshLeft, Search, Tools } from '@element-plus/icons-vue'
import { fetchUserOptions } from '@/api/admin'
import {
vehicleApi,
vehicleMaintenanceApplyApi,
vehicleMaintenanceProcessApi,
type Vehicle,
type VehicleMaintenanceProcessBoardItem
} from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
getPlMaintenanceApplyStatusLabel,
getPlMaintenanceApplyStatusTagType,
getPlMaintenanceTypeLabel,
getPlMaintenanceTypeTagType,
PL_MAINTENANCE_TYPE_PRESETS
} from '@/utils/pl-display'
type DialogMode = 'process' | 'edit' | 'supplement'
const userStore = useUserStore()
const currentUserId = computed(() => {
const info = userStore.info as Record<string, unknown> | undefined
return String(info?.id || info?.userId || '')
})
const userOptions = ref<
Array<{ userId: string; nickName: string; displayName: string }>
>([])
const loading = ref(false)
const submitLoading = ref(false)
const deleteLoadingMap = reactive<Record<string, boolean>>({})
const tableData = ref<VehicleMaintenanceProcessBoardItem[]>([])
const vehicleOptions = ref<Vehicle[]>([])
const applyDateRange = ref<string[]>([])
const processDateRange = ref<string[]>([])
const searchForm = reactive({
q: '',
maintenanceType: '',
applyStatus: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogMode = ref<DialogMode>('process')
const currentApplyRow = ref<VehicleMaintenanceProcessBoardItem | null>(null)
const formRef = ref()
const attachmentUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const formData = reactive({
id: '',
processNo: '',
applyId: '',
licensePlate: '',
handlerId: '',
processDate: '',
amount: null as number | null,
attachmentUrls: '',
remark: ''
})
const formRules = computed(() => ({
licensePlate:
dialogMode.value === 'supplement'
? [{ required: true, message: '请选择车牌号', trigger: 'change' }]
: [],
handlerId: [{ required: true, message: '请选择处理人', trigger: 'change' }],
processDate: [{ required: true, message: '请选择处理日期', trigger: 'change' }]
}))
async function loadUserOptions() {
try {
const res = await fetchUserOptions()
const data: unknown = (res as { data?: unknown })?.data ?? res ?? []
userOptions.value = (Array.isArray(data) ? data : []).map((u: Record<string, unknown>) => {
const name = String(u.label || u.nickName || '')
const account = String(u.userNo || '')
return {
userId: String(u.value ?? u.id ?? ''),
nickName: name,
displayName: account ? `${name}(${account})` : name
}
})
} catch {
userOptions.value = []
}
}
async function loadVehicleOptions() {
const res = await vehicleApi.list()
vehicleOptions.value = (res.data ?? res) || []
}
async function showMaintenanceReminders(licensePlate: string) {
if (!licensePlate) return
try {
const res = await vehicleMaintenanceApplyApi.reminders(licensePlate)
const items = (res.data ?? res) || []
if (!items.length) return
const text = items
.map((item, index) => {
const lines = [
`【${index + 1}】申请单号:${item.applyNo}`,
`维保类型:${item.maintenanceType}`,
`开始日期:${item.startDate}`,
`预计取车:${item.expectedPickupDate || '—'}`,
`维保内容:${item.maintenanceContent || '—'}`,
`维修店地址:${item.shopAddress || '—'}`
]
return lines.join('\n')
})
.join('\n\n')
await ElMessageBox.alert(text, '车辆维保提醒', { confirmButtonText: '知道了' })
} catch {
// ignore reminder fetch errors
}
}
function handleLicensePlateChange(licensePlate: string) {
showMaintenanceReminders(licensePlate)
}
async function fetchData() {
loading.value = true
try {
const res = await vehicleMaintenanceProcessApi.processBoard({
q: searchForm.q || undefined,
maintenanceType: searchForm.maintenanceType || undefined,
applyStatus: searchForm.applyStatus || undefined,
applyDateFrom: applyDateRange.value?.[0],
applyDateTo: applyDateRange.value?.[1],
processDateFrom: processDateRange.value?.[0],
processDateTo: processDateRange.value?.[1],
page: pagination.page,
pageSize: pagination.pageSize
})
const data = res.data ?? res
tableData.value = data.items || []
pagination.total = data.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
searchForm.q = ''
searchForm.maintenanceType = ''
searchForm.applyStatus = ''
applyDateRange.value = []
processDateRange.value = []
handleSearch()
}
function resetForm() {
formData.id = ''
formData.processNo = ''
formData.applyId = ''
formData.licensePlate = ''
formData.handlerId = ''
formData.processDate = ''
formData.amount = null
formData.attachmentUrls = ''
formData.remark = ''
currentApplyRow.value = null
}
async function openDialogWithDefaults(mode: DialogMode, title: string) {
dialogMode.value = mode
dialogTitle.value = title
formData.processDate = new Date().toISOString().slice(0, 10)
formData.handlerId = currentUserId.value
await loadUserOptions()
dialogVisible.value = true
}
async function handleProcess(row: VehicleMaintenanceProcessBoardItem) {
resetForm()
currentApplyRow.value = row
formData.applyId = row.applyId
formData.licensePlate = row.licensePlate
await openDialogWithDefaults('process', '处理登记')
}
async function handleSupplement() {
resetForm()
await loadVehicleOptions()
await openDialogWithDefaults('supplement', '无申请补录')
}
async function handleEdit(row: VehicleMaintenanceProcessBoardItem) {
if (!row.processId) return
resetForm()
dialogMode.value = 'edit'
dialogTitle.value = '编辑处理登记'
currentApplyRow.value = row
await loadUserOptions()
const res = await vehicleMaintenanceProcessApi.get(row.processId)
const detail = res.data ?? res
Object.assign(formData, {
id: detail.id,
processNo: detail.processNo,
applyId: detail.applyId || row.applyId,
licensePlate: detail.licensePlate || row.licensePlate,
handlerId: detail.handlerId,
processDate: detail.processDate,
amount: detail.amount != null ? Number(detail.amount) : null,
attachmentUrls: detail.attachmentUrls || '',
remark: detail.remark || ''
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value?.validate()
if (attachmentUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
applyId: formData.applyId || undefined,
licensePlate: dialogMode.value === 'supplement' ? formData.licensePlate : undefined,
handlerId: formData.handlerId,
processDate: formData.processDate,
amount: formData.amount ?? undefined,
attachmentUrls: formData.attachmentUrls || undefined,
remark: formData.remark || undefined
}
if (dialogMode.value === 'edit') {
await vehicleMaintenanceProcessApi.update(formData.id, payload)
ElMessage.success('更新成功')
} else {
await vehicleMaintenanceProcessApi.create(payload)
ElMessage.success('处理登记成功')
}
dialogVisible.value = false
fetchData()
} finally {
submitLoading.value = false
}
}
async function handleDelete(row: VehicleMaintenanceProcessBoardItem) {
if (!row.processId) return
await ElMessageBox.confirm('确定撤销该处理登记吗?关联申请将回退为待处理。', '提示', { type: 'warning' })
deleteLoadingMap[row.processId] = true
try {
await vehicleMaintenanceProcessApi.delete(row.processId)
ElMessage.success('已撤销处理')
fetchData()
} finally {
deleteLoadingMap[row.processId] = false
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
.apply-summary-block {
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--primary-bg);
}
.apply-summary-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.apply-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
font-size: 13px;
color: var(--text);
.label {
display: inline-block;
min-width: 72px;
margin-right: 6px;
color: var(--text-muted);
}
}
.apply-summary-span-2 {
grid-column: 1 / -1;
}
</style>
@@ -0,0 +1,508 @@
<!-- ETC加油申请 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">ETC加油申请</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="申请单号/车牌">
<el-input v-model="searchForm.q" placeholder="搜索申请单号或车牌" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="充值类型">
<el-select v-model="searchForm.rechargeType" placeholder="全部" clearable>
<el-option label="ETC" value="etc" />
<el-option label="加油" value="fuel" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable>
<el-option label="待发放" value="pending" />
<el-option label="已发放" value="disbursed" />
<el-option label="已作废" value="cancelled" />
</el-select>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增申请
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column prop="applyNo" label="申请单号" min-width="170" show-overflow-tooltip />
<el-table-column prop="licensePlate" label="车牌号" min-width="120" align="center" />
<el-table-column prop="rechargeType" label="充值类型" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlRechargeTypeTagType(row.rechargeType)" size="small">
{{ getPlRechargeTypeLabel(row.rechargeType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="applyDate" label="申请日期" min-width="120" align="center" />
<el-table-column prop="odometerKm" label="期初公里数" min-width="110" align="center">
<template #default="{ row }">{{ row.odometerKm ?? '—' }}</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" />
<el-table-column prop="status" label="状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlRechargeApplyStatusTagType(row.status)" size="small">
{{ getPlRechargeApplyStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
<el-table-column label="操作" min-width="280" fixed="right" align="center" class-name="col-actions">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<template v-if="row.status === 'pending'">
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="warning" :loading="cancelLoadingMap[row.id]" @click="handleCancel(row)">
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button link type="danger" :loading="deleteLoadingMap[row.id]" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</section>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="720px"
align-center
class="dialog-form-layout order-module-dialog-mask"
destroy-on-close
@closed="resetForm"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item v-if="formData.applyNo" label="申请单号">
<el-input v-model="formData.applyNo" disabled />
</el-form-item>
<el-form-item label="申请人" prop="applicantId">
<el-select
v-model="formData.applicantId"
filterable
placeholder="请选择申请人"
style="width: 100%"
>
<el-option
v-for="user in userOptions"
:key="user.userId"
:label="user.displayName"
:value="user.userId"
/>
</el-select>
</el-form-item>
<el-form-item label="车牌号" prop="licensePlate">
<el-select v-model="formData.licensePlate" filterable placeholder="请选择车牌" style="width: 100%">
<el-option v-for="item in vehicleOptions" :key="item.id" :label="item.licensePlate" :value="item.licensePlate" />
</el-select>
</el-form-item>
<el-form-item label="充值类型" prop="rechargeType">
<el-select v-model="formData.rechargeType" placeholder="请选择" style="width: 100%">
<el-option label="ETC" value="etc" />
<el-option label="加油" value="fuel" />
</el-select>
</el-form-item>
<el-form-item label="申请日期" prop="applyDate">
<el-date-picker v-model="formData.applyDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="期初公里数" prop="odometerKm">
<el-input-number v-model="formData.odometerKm" :min="0" :precision="1" style="width: 100%" />
</el-form-item>
<el-form-item label="附件" class="form-col-span-2">
<AttachmentUploadField
ref="attachmentUploadRef"
v-model="formData.attachmentUrls"
folder="pl/vehicle-recharge/apply"
accept="image/*,.pdf"
:limit="5"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="备注" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewDetail?.applyNo || '申请详情'"
:sections="viewSections"
:loading="viewLoading"
width="720px"
modal-class="order-module-dialog-mask"
append-to-body
/>
</div>
</template>
<script setup lang="ts">
import {
Check,
CircleClose,
Close,
Delete,
Edit,
Plus,
RefreshLeft,
Search,
View
} from '@element-plus/icons-vue'
import {
vehicleApi,
vehicleRechargeApplyApi,
type Vehicle,
type VehicleRechargeApply
} from '@/api/pl'
import { fetchUserOptions } from '@/api/admin'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
getPlRechargeApplyStatusLabel,
getPlRechargeApplyStatusTagType,
getPlRechargeTypeLabel,
getPlRechargeTypeTagType
} from '@/utils/pl-display'
const userStore = useUserStore()
const currentUserId = computed(() => {
const info = userStore.info as Record<string, unknown> | undefined
return String(info?.id || info?.userId || '')
})
const userOptions = ref<
Array<{ userId: string; nickName: string; displayName: string }>
>([])
const loading = ref(false)
const submitLoading = ref(false)
const cancelLoadingMap = reactive<Record<string, boolean>>({})
const deleteLoadingMap = reactive<Record<string, boolean>>({})
const tableData = ref<VehicleRechargeApply[]>([])
const selectedRows = ref<VehicleRechargeApply[]>([])
const vehicleOptions = ref<Vehicle[]>([])
const dateRange = ref<string[]>([])
const searchForm = reactive({
q: '',
rechargeType: '',
status: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const formRef = ref()
const attachmentUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const formData = reactive({
id: '',
applyNo: '',
applicantId: '',
licensePlate: '',
rechargeType: 'etc' as 'etc' | 'fuel',
applyDate: '',
odometerKm: null as number | null,
attachmentUrls: '',
remark: ''
})
const formRules = {
applicantId: [{ required: true, message: '请选择申请人', trigger: 'change' }],
licensePlate: [{ required: true, message: '请选择车牌号', trigger: 'change' }],
rechargeType: [{ required: true, message: '请选择充值类型', trigger: 'change' }],
applyDate: [{ required: true, message: '请选择申请日期', trigger: 'change' }]
}
const viewVisible = ref(false)
const viewLoading = ref(false)
const viewDetail = ref<VehicleRechargeApply | null>(null)
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
if (!d) return []
return [
{
title: '申请信息',
fields: [
{ label: '申请单号', value: d.applyNo },
{ label: '车牌号', value: d.licensePlate },
{
label: '充值类型',
value: getPlRechargeTypeLabel(d.rechargeType),
tagType: getPlRechargeTypeTagType(d.rechargeType)
},
{ label: '申请日期', value: d.applyDate },
{ label: '期初公里数', value: d.odometerKm != null ? String(d.odometerKm) : '—' },
{ label: '申请人', value: d.applicantName || '—' },
{
label: '状态',
value: getPlRechargeApplyStatusLabel(d.status),
tagType: getPlRechargeApplyStatusTagType(d.status)
},
{ label: '备注', value: d.remark || '—', span: 2 }
]
}
]
})
async function loadUserOptions() {
try {
const res = await fetchUserOptions()
const data: unknown = (res as { data?: unknown })?.data ?? res ?? []
userOptions.value = (Array.isArray(data) ? data : []).map((u: Record<string, unknown>) => {
const name = String(u.label || u.nickName || '')
const account = String(u.userNo || '')
return {
userId: String(u.value ?? u.id ?? ''),
nickName: name,
displayName: account ? `${name}(${account})` : name
}
})
} catch {
userOptions.value = []
}
}
async function loadVehicleOptions() {
const res = await vehicleApi.list()
vehicleOptions.value = (res.data ?? res) || []
}
async function fetchData() {
loading.value = true
try {
const res = await vehicleRechargeApplyApi.list({
q: searchForm.q || undefined,
rechargeType: searchForm.rechargeType || undefined,
status: searchForm.status || undefined,
applyDateFrom: dateRange.value?.[0],
applyDateTo: dateRange.value?.[1],
page: pagination.page,
pageSize: pagination.pageSize
})
const data = res.data ?? res
tableData.value = data.items || []
pagination.total = data.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
searchForm.q = ''
searchForm.rechargeType = ''
searchForm.status = ''
dateRange.value = []
handleSearch()
}
function handleSelectionChange(rows: VehicleRechargeApply[]) {
selectedRows.value = rows
}
function resetForm() {
formData.id = ''
formData.applyNo = ''
formData.applicantId = ''
formData.licensePlate = ''
formData.rechargeType = 'etc'
formData.applyDate = ''
formData.odometerKm = null
formData.attachmentUrls = ''
formData.remark = ''
}
async function handleAdd() {
resetForm()
isEdit.value = false
dialogTitle.value = '新增申请'
formData.applyDate = new Date().toISOString().slice(0, 10)
formData.applicantId = currentUserId.value
await loadUserOptions()
dialogVisible.value = true
}
async function handleEdit(row: VehicleRechargeApply) {
isEdit.value = true
dialogTitle.value = '编辑申请'
await loadUserOptions()
Object.assign(formData, {
id: row.id,
applyNo: row.applyNo,
applicantId: row.applicantId || '',
licensePlate: row.licensePlate,
rechargeType: row.rechargeType,
applyDate: row.applyDate,
odometerKm: row.odometerKm ?? null,
attachmentUrls: row.attachmentUrls || '',
remark: row.remark || ''
})
dialogVisible.value = true
}
async function handleView(row: VehicleRechargeApply) {
viewVisible.value = true
viewLoading.value = true
try {
const res = await vehicleRechargeApplyApi.get(row.id)
viewDetail.value = res.data ?? res
} finally {
viewLoading.value = false
}
}
async function handleSubmit() {
await formRef.value?.validate()
if (attachmentUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
applicantId: formData.applicantId,
licensePlate: formData.licensePlate,
rechargeType: formData.rechargeType,
applyDate: formData.applyDate,
odometerKm: formData.odometerKm,
attachmentUrls: formData.attachmentUrls || undefined,
remark: formData.remark || undefined
}
if (isEdit.value) {
await vehicleRechargeApplyApi.update(formData.id, payload)
ElMessage.success('更新成功')
} else {
await vehicleRechargeApplyApi.create(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} finally {
submitLoading.value = false
}
}
async function handleCancel(row: VehicleRechargeApply) {
await ElMessageBox.confirm('确定作废该申请吗?', '提示', { type: 'warning' })
cancelLoadingMap[row.id] = true
try {
await vehicleRechargeApplyApi.cancel(row.id)
ElMessage.success('已作废')
fetchData()
} finally {
cancelLoadingMap[row.id] = false
}
}
async function handleDelete(row: VehicleRechargeApply) {
await ElMessageBox.confirm('确定删除该申请吗?', '提示', { type: 'warning' })
deleteLoadingMap[row.id] = true
try {
await vehicleRechargeApplyApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} finally {
deleteLoadingMap[row.id] = false
}
}
onMounted(async () => {
await Promise.all([loadVehicleOptions(), loadUserOptions()])
fetchData()
})
</script>
@@ -0,0 +1,604 @@
<!-- ETC加油发放登记 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">ETC加油发放登记</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="申请单号/车牌/订单号">
<el-input v-model="searchForm.q" placeholder="搜索关键词" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="充值类型">
<el-select v-model="searchForm.rechargeType" placeholder="全部" clearable>
<el-option label="ETC" value="etc" />
<el-option label="加油" value="fuel" />
</el-select>
</el-form-item>
<el-form-item label="申请状态">
<el-select v-model="searchForm.applyStatus" placeholder="全部" clearable>
<el-option label="待发放" value="pending" />
<el-option label="已发放" value="disbursed" />
<el-option label="已作废" value="cancelled" />
</el-select>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="applyDateRange"
type="daterange"
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="充值日期">
<el-date-picker
v-model="rechargeDateRange"
type="daterange"
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">共 <span class="sel-num">{{ pagination.total }}</span> 条申请</div>
<el-button @click="handleSupplement">
<el-icon><Plus /></el-icon>无申请补录
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="tableData" v-loading="loading" stripe row-key="applyId">
<el-table-column prop="applyNo" label="申请单号" min-width="170" show-overflow-tooltip fixed="left" />
<el-table-column prop="applyDate" label="申请日期" min-width="120" align="center" />
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" />
<el-table-column prop="odometerKm" label="期初公里数" min-width="110" align="right">
<template #default="{ row }">
{{ row.odometerKm != null ? Number(row.odometerKm).toFixed(1) : '—' }}
</template>
</el-table-column>
<el-table-column prop="applyStatus" label="申请状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlRechargeApplyStatusTagType(row.applyStatus)" size="small">
{{ getPlRechargeApplyStatusLabel(row.applyStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="licensePlate" label="车牌号" min-width="110" align="center" />
<el-table-column prop="rechargeType" label="充值类型" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlRechargeTypeTagType(row.rechargeType)" size="small">
{{ getPlRechargeTypeLabel(row.rechargeType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="rechargeOrderNo" label="充值订单号" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ row.rechargeOrderNo || '—' }}</template>
</el-table-column>
<el-table-column prop="rechargeDate" label="充值日期" min-width="120" align="center">
<template #default="{ row }">{{ row.rechargeDate || '—' }}</template>
</el-table-column>
<el-table-column prop="cardNo" label="卡号" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ row.cardNo || '—' }}</template>
</el-table-column>
<el-table-column prop="gasStation" label="加油站/渠道" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ row.gasStation || '—' }}</template>
</el-table-column>
<el-table-column prop="amount" label="金额(元)" min-width="110" align="right">
<template #default="{ row }">
{{ row.amount != null ? Number(row.amount).toFixed(2) : '—' }}
</template>
</el-table-column>
<el-table-column prop="recordCreateByName" label="登记人" min-width="100" align="center">
<template #default="{ row }">{{ row.recordCreateByName || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="300" fixed="right" align="center" class-name="col-actions">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="row.applyStatus === 'pending'"
link
type="primary"
@click="handleDisburse(row)"
>
<el-icon><Money /></el-icon>发放
</el-button>
<template v-if="row.applyStatus === 'disbursed' && row.recordId">
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
link
type="danger"
:loading="deleteLoadingMap[row.recordId]"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>撤销发放
</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</section>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="720px"
align-center
class="dialog-form-layout order-module-dialog-mask"
destroy-on-close
@closed="resetForm"
>
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="top" scroll-to-error>
<div v-if="dialogMode === 'disburse' && currentApplyRow" class="apply-summary-block">
<div class="apply-summary-title">关联申请</div>
<div class="apply-summary-grid">
<div><span class="label">申请单号</span>{{ currentApplyRow.applyNo }}</div>
<div><span class="label">申请日期</span>{{ currentApplyRow.applyDate }}</div>
<div><span class="label">车牌号</span>{{ currentApplyRow.licensePlate }}</div>
<div>
<span class="label">充值类型</span>
{{ getPlRechargeTypeLabel(currentApplyRow.rechargeType) }}
</div>
<div><span class="label">申请人</span>{{ currentApplyRow.applicantName || '—' }}</div>
<div>
<span class="label">期初公里数</span>
{{
currentApplyRow.odometerKm != null
? `${Number(currentApplyRow.odometerKm).toFixed(1)} km`
: '—'
}}
</div>
</div>
</div>
<div class="form-grid-2">
<el-form-item label="充值订单号">
<el-input :model-value="formData.rechargeOrderNo || '保存后自动生成'" disabled />
</el-form-item>
<el-form-item label="登记人">
<el-input :model-value="currentUserName" disabled />
</el-form-item>
<template v-if="dialogMode === 'supplement'">
<el-form-item label="车牌号" prop="licensePlate">
<el-select
v-model="formData.licensePlate"
filterable
placeholder="请选择车牌"
style="width: 100%"
>
<el-option
v-for="item in vehicleOptions"
:key="item.id"
:label="item.licensePlate"
:value="item.licensePlate"
/>
</el-select>
</el-form-item>
<el-form-item label="充值类型" prop="rechargeType">
<el-select v-model="formData.rechargeType" placeholder="请选择" style="width: 100%">
<el-option label="ETC" value="etc" />
<el-option label="加油" value="fuel" />
</el-select>
</el-form-item>
</template>
<el-form-item label="充值日期" prop="rechargeDate">
<el-date-picker v-model="formData.rechargeDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="卡号" prop="cardNo">
<el-input v-model="formData.cardNo" placeholder="ETC卡号/油卡号" />
</el-form-item>
<el-form-item label="加油站/渠道" prop="gasStation">
<el-select
v-model="formData.gasStation"
filterable
allow-create
default-first-option
placeholder="选择或输入"
style="width: 100%"
>
<el-option v-for="item in PL_GAS_STATION_PRESETS" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="充值金额(元)" prop="amount">
<el-input-number v-model="formData.amount" :min="0.01" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="附件" class="form-col-span-2">
<AttachmentUploadField
ref="attachmentUploadRef"
v-model="formData.attachmentUrls"
folder="pl/vehicle-recharge/record"
accept="image/*,.pdf"
:limit="5"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="备注" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.applyNo || '发放详情'"
:sections="viewSections"
width="760px"
modal-class="order-module-dialog-mask"
append-to-body
/>
</div>
</template>
<script setup lang="ts">
import { Check, Close, Delete, Edit, Money, Plus, RefreshLeft, Search, View } from '@element-plus/icons-vue'
import {
vehicleApi,
vehicleRechargeRecordApi,
type Vehicle,
type VehicleRechargeDisburseBoardItem
} from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
getPlRechargeApplyStatusLabel,
getPlRechargeApplyStatusTagType,
getPlRechargeTypeLabel,
getPlRechargeTypeTagType,
PL_GAS_STATION_PRESETS
} from '@/utils/pl-display'
type DialogMode = 'disburse' | 'edit' | 'supplement'
const userStore = useUserStore()
const currentUserName = computed(() => userStore.info?.userName || '—')
const loading = ref(false)
const submitLoading = ref(false)
const deleteLoadingMap = reactive<Record<string, boolean>>({})
const tableData = ref<VehicleRechargeDisburseBoardItem[]>([])
const vehicleOptions = ref<Vehicle[]>([])
const applyDateRange = ref<string[]>([])
const rechargeDateRange = ref<string[]>([])
const searchForm = reactive({
q: '',
rechargeType: '',
applyStatus: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogMode = ref<DialogMode>('disburse')
const currentApplyRow = ref<VehicleRechargeDisburseBoardItem | null>(null)
const formRef = ref()
const attachmentUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const formData = reactive({
id: '',
rechargeOrderNo: '',
applyId: '',
licensePlate: '',
rechargeType: 'etc' as 'etc' | 'fuel',
rechargeDate: '',
cardNo: '',
gasStation: '',
amount: null as number | null,
attachmentUrls: '',
remark: ''
})
const formRules = computed(() => ({
licensePlate:
dialogMode.value === 'supplement'
? [{ required: true, message: '请选择车牌号', trigger: 'change' }]
: [],
rechargeType:
dialogMode.value === 'supplement'
? [{ required: true, message: '请选择充值类型', trigger: 'change' }]
: [],
rechargeDate: [{ required: true, message: '请选择充值日期', trigger: 'change' }],
amount: [{ required: true, message: '请输入充值金额', trigger: 'blur' }]
}))
const viewVisible = ref(false)
const viewRow = ref<VehicleRechargeDisburseBoardItem | null>(null)
const viewSections = computed<ViewSection[]>(() => {
const row = viewRow.value
if (!row) return []
const sections: ViewSection[] = [
{
title: '申请信息',
fields: [
{ label: '申请单号', value: row.applyNo },
{ label: '申请日期', value: row.applyDate },
{
label: '申请状态',
value: getPlRechargeApplyStatusLabel(row.applyStatus),
tagType: getPlRechargeApplyStatusTagType(row.applyStatus)
},
{ label: '车牌号', value: row.licensePlate },
{
label: '充值类型',
value: getPlRechargeTypeLabel(row.rechargeType),
tagType: getPlRechargeTypeTagType(row.rechargeType)
},
{ label: '申请人', value: row.applicantName || '—' },
{
label: '期初公里数',
value: row.odometerKm != null ? `${Number(row.odometerKm).toFixed(1)} km` : '—'
},
{ label: '申请备注', value: row.applyRemark || '—', span: 2 }
]
}
]
if (row.recordId) {
sections.push({
title: '发放信息',
fields: [
{ label: '充值订单号', value: row.rechargeOrderNo || '—' },
{ label: '充值日期', value: row.rechargeDate || '—' },
{ label: '卡号', value: row.cardNo || '—' },
{ label: '加油站/渠道', value: row.gasStation || '—' },
{
label: '充值金额',
value: row.amount != null ? `${Number(row.amount).toFixed(2)} 元` : '—'
},
{ label: '登记人', value: row.recordCreateByName || '—' },
{ label: '登记备注', value: row.recordRemark || '—', span: 2 }
]
})
}
return sections
})
async function loadVehicleOptions() {
const vehicles = await vehicleApi.list()
vehicleOptions.value = (vehicles.data ?? vehicles) || []
}
async function fetchData() {
loading.value = true
try {
const res = await vehicleRechargeRecordApi.disburseBoard({
q: searchForm.q || undefined,
rechargeType: searchForm.rechargeType || undefined,
applyStatus: searchForm.applyStatus || undefined,
applyDateFrom: applyDateRange.value?.[0],
applyDateTo: applyDateRange.value?.[1],
rechargeDateFrom: rechargeDateRange.value?.[0],
rechargeDateTo: rechargeDateRange.value?.[1],
page: pagination.page,
pageSize: pagination.pageSize
})
const data = res.data ?? res
tableData.value = data.items || []
pagination.total = data.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
searchForm.q = ''
searchForm.rechargeType = ''
searchForm.applyStatus = ''
applyDateRange.value = []
rechargeDateRange.value = []
handleSearch()
}
function resetForm() {
formData.id = ''
formData.rechargeOrderNo = ''
formData.applyId = ''
formData.licensePlate = ''
formData.rechargeType = 'etc'
formData.rechargeDate = ''
formData.cardNo = ''
formData.gasStation = ''
formData.amount = null
formData.attachmentUrls = ''
formData.remark = ''
currentApplyRow.value = null
}
function handleDisburse(row: VehicleRechargeDisburseBoardItem) {
resetForm()
dialogMode.value = 'disburse'
dialogTitle.value = '发放登记'
currentApplyRow.value = row
formData.applyId = row.applyId
formData.licensePlate = row.licensePlate
formData.rechargeType = row.rechargeType
formData.rechargeDate = new Date().toISOString().slice(0, 10)
dialogVisible.value = true
}
async function handleSupplement() {
resetForm()
dialogMode.value = 'supplement'
dialogTitle.value = '无申请补录'
formData.rechargeDate = new Date().toISOString().slice(0, 10)
await loadVehicleOptions()
dialogVisible.value = true
}
async function handleEdit(row: VehicleRechargeDisburseBoardItem) {
if (!row.recordId) return
resetForm()
dialogMode.value = 'edit'
dialogTitle.value = '编辑发放登记'
currentApplyRow.value = row
const res = await vehicleRechargeRecordApi.get(row.recordId)
const detail = res.data ?? res
Object.assign(formData, {
id: detail.id,
rechargeOrderNo: detail.rechargeOrderNo,
applyId: detail.applyId || row.applyId,
licensePlate: detail.licensePlate || row.licensePlate,
rechargeType: detail.rechargeType,
rechargeDate: detail.rechargeDate,
cardNo: detail.cardNo || '',
gasStation: detail.gasStation || '',
amount: Number(detail.amount),
attachmentUrls: detail.attachmentUrls || '',
remark: detail.remark || ''
})
dialogVisible.value = true
}
function handleView(row: VehicleRechargeDisburseBoardItem) {
viewRow.value = row
viewVisible.value = true
}
async function handleSubmit() {
await formRef.value?.validate()
if (attachmentUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
applyId: formData.applyId || undefined,
licensePlate: dialogMode.value === 'supplement' ? formData.licensePlate : undefined,
rechargeType:
dialogMode.value === 'supplement'
? formData.rechargeType
: dialogMode.value === 'edit'
? formData.rechargeType
: currentApplyRow.value?.rechargeType,
rechargeDate: formData.rechargeDate,
cardNo: formData.cardNo || undefined,
gasStation: formData.gasStation || undefined,
amount: formData.amount,
attachmentUrls: formData.attachmentUrls || undefined,
remark: formData.remark || undefined
}
if (dialogMode.value === 'edit') {
await vehicleRechargeRecordApi.update(formData.id, payload)
ElMessage.success('更新成功')
} else {
await vehicleRechargeRecordApi.create(payload)
ElMessage.success('发放登记成功')
}
dialogVisible.value = false
fetchData()
} finally {
submitLoading.value = false
}
}
async function handleDelete(row: VehicleRechargeDisburseBoardItem) {
if (!row.recordId) return
await ElMessageBox.confirm('确定撤销该发放登记吗?关联申请将回退为待发放。', '提示', { type: 'warning' })
deleteLoadingMap[row.recordId] = true
try {
await vehicleRechargeRecordApi.delete(row.recordId)
ElMessage.success('已撤销发放')
fetchData()
} finally {
deleteLoadingMap[row.recordId] = false
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
.apply-summary-block {
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--primary-bg);
}
.apply-summary-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.apply-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
font-size: 13px;
color: var(--text);
.label {
display: inline-block;
min-width: 72px;
margin-right: 6px;
color: var(--text-muted);
}
}
</style>
@@ -0,0 +1,523 @@
<!-- 车辆尿素申请 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">车辆尿素申请</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="申请单号/车牌">
<el-input v-model="searchForm.q" placeholder="搜索申请单号或车牌" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable>
<el-option label="待发放" value="pending" />
<el-option label="已发放" value="disbursed" />
<el-option label="已作废" value="cancelled" />
</el-select>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增申请
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column prop="applyNo" label="申请单号" min-width="170" show-overflow-tooltip />
<el-table-column prop="licensePlate" label="车牌号" min-width="120" align="center" />
<el-table-column prop="applyDate" label="申请时间" min-width="120" align="center" />
<el-table-column prop="warehouseInDate" label="入库时间" min-width="120" align="center" />
<el-table-column prop="unit" label="单位" min-width="90" align="center" />
<el-table-column prop="quantity" label="入库数" min-width="100" align="right">
<template #default="{ row }">{{ Number(row.quantity).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="applyAmount" label="申请金额(元)" min-width="120" align="right">
<template #default="{ row }">{{ Number(row.applyAmount).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" />
<el-table-column prop="status" label="状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlUreaApplyStatusTagType(row.status)" size="small">
{{ getPlUreaApplyStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
<el-table-column label="操作" min-width="280" fixed="right" align="center" class-name="col-actions">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<template v-if="row.status === 'pending'">
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="warning" :loading="cancelLoadingMap[row.id]" @click="handleCancel(row)">
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button link type="danger" :loading="deleteLoadingMap[row.id]" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</section>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="720px"
align-center
class="dialog-form-layout order-module-dialog-mask"
destroy-on-close
@closed="resetForm"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item v-if="formData.applyNo" label="申请单号">
<el-input v-model="formData.applyNo" disabled />
</el-form-item>
<el-form-item label="申请人" prop="applicantId">
<el-select
v-model="formData.applicantId"
filterable
placeholder="请选择申请人"
style="width: 100%"
>
<el-option
v-for="user in userOptions"
:key="user.userId"
:label="user.displayName"
:value="user.userId"
/>
</el-select>
</el-form-item>
<el-form-item label="车牌号" prop="licensePlate">
<el-select v-model="formData.licensePlate" filterable placeholder="请选择车牌" style="width: 100%">
<el-option v-for="item in vehicleOptions" :key="item.id" :label="item.licensePlate" :value="item.licensePlate" />
</el-select>
</el-form-item>
<el-form-item label="申请时间" prop="applyDate">
<el-date-picker v-model="formData.applyDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="入库时间" prop="warehouseInDate">
<el-date-picker v-model="formData.warehouseInDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-select
v-model="formData.unit"
filterable
allow-create
default-first-option
placeholder="选择或输入"
style="width: 100%"
>
<el-option v-for="item in PL_UREA_UNIT_PRESETS" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="入库数" prop="quantity">
<el-input-number v-model="formData.quantity" :min="0.01" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="申请金额(元)" prop="applyAmount">
<el-input-number v-model="formData.applyAmount" :min="0.01" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="附件" class="form-col-span-2">
<AttachmentUploadField
ref="attachmentUploadRef"
v-model="formData.attachmentUrls"
folder="pl/vehicle-urea/apply"
accept="image/*,.pdf"
:limit="5"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="备注" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewDetail?.applyNo || '申请详情'"
:sections="viewSections"
:loading="viewLoading"
width="720px"
modal-class="order-module-dialog-mask"
append-to-body
/>
</div>
</template>
<script setup lang="ts">
import {
Check,
CircleClose,
Close,
Delete,
Edit,
Plus,
RefreshLeft,
Search,
View
} from '@element-plus/icons-vue'
import {
vehicleApi,
vehicleUreaApplyApi,
type Vehicle,
type VehicleUreaApply
} from '@/api/pl'
import { fetchUserOptions } from '@/api/admin'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import {
getPlUreaApplyStatusLabel,
getPlUreaApplyStatusTagType,
PL_UREA_UNIT_PRESETS
} from '@/utils/pl-display'
const userStore = useUserStore()
const currentUserId = computed(() => {
const info = userStore.info as Record<string, unknown> | undefined
return String(info?.id || info?.userId || '')
})
const userOptions = ref<
Array<{ userId: string; nickName: string; displayName: string }>
>([])
const loading = ref(false)
const submitLoading = ref(false)
const cancelLoadingMap = reactive<Record<string, boolean>>({})
const deleteLoadingMap = reactive<Record<string, boolean>>({})
const tableData = ref<VehicleUreaApply[]>([])
const selectedRows = ref<VehicleUreaApply[]>([])
const vehicleOptions = ref<Vehicle[]>([])
const dateRange = ref<string[]>([])
const searchForm = reactive({
q: '',
status: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const formRef = ref()
const attachmentUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const formData = reactive({
id: '',
applyNo: '',
applicantId: '',
licensePlate: '',
applyDate: '',
warehouseInDate: '',
unit: '吨桶',
quantity: null as number | null,
applyAmount: null as number | null,
attachmentUrls: '',
remark: ''
})
const formRules = {
applicantId: [{ required: true, message: '请选择申请人', trigger: 'change' }],
licensePlate: [{ required: true, message: '请选择车牌号', trigger: 'change' }],
applyDate: [{ required: true, message: '请选择申请时间', trigger: 'change' }],
warehouseInDate: [{ required: true, message: '请选择入库时间', trigger: 'change' }],
unit: [{ required: true, message: '请填写单位', trigger: 'change' }],
quantity: [{ required: true, message: '请填写入库数', trigger: 'blur' }],
applyAmount: [{ required: true, message: '请填写申请金额', trigger: 'blur' }]
}
const viewVisible = ref(false)
const viewLoading = ref(false)
const viewDetail = ref<VehicleUreaApply | null>(null)
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
if (!d) return []
return [
{
title: '申请信息',
fields: [
{ label: '申请单号', value: d.applyNo },
{ label: '车牌号', value: d.licensePlate },
{ label: '申请时间', value: d.applyDate },
{ label: '入库时间', value: d.warehouseInDate },
{ label: '单位', value: d.unit },
{ label: '入库数', value: Number(d.quantity).toFixed(2) },
{ label: '申请金额', value: `${Number(d.applyAmount).toFixed(2)} 元` },
{ label: '申请人', value: d.applicantName || '—' },
{
label: '状态',
value: getPlUreaApplyStatusLabel(d.status),
tagType: getPlUreaApplyStatusTagType(d.status)
},
{ label: '备注', value: d.remark || '—', span: 2 }
]
}
]
})
async function loadUserOptions() {
try {
const res = await fetchUserOptions()
const data: unknown = (res as { data?: unknown })?.data ?? res ?? []
userOptions.value = (Array.isArray(data) ? data : []).map((u: Record<string, unknown>) => {
const name = String(u.label || u.nickName || '')
const account = String(u.userNo || '')
return {
userId: String(u.value ?? u.id ?? ''),
nickName: name,
displayName: account ? `${name}(${account})` : name
}
})
} catch {
userOptions.value = []
}
}
async function loadVehicleOptions() {
const res = await vehicleApi.list()
vehicleOptions.value = (res.data ?? res) || []
}
async function fetchData() {
loading.value = true
try {
const res = await vehicleUreaApplyApi.list({
q: searchForm.q || undefined,
status: searchForm.status || undefined,
applyDateFrom: dateRange.value?.[0],
applyDateTo: dateRange.value?.[1],
page: pagination.page,
pageSize: pagination.pageSize
})
const data = res.data ?? res
tableData.value = data.items || []
pagination.total = data.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
searchForm.q = ''
searchForm.status = ''
dateRange.value = []
handleSearch()
}
function handleSelectionChange(rows: VehicleUreaApply[]) {
selectedRows.value = rows
}
function resetForm() {
formData.id = ''
formData.applyNo = ''
formData.applicantId = ''
formData.licensePlate = ''
formData.applyDate = ''
formData.warehouseInDate = ''
formData.unit = '吨桶'
formData.quantity = null
formData.applyAmount = null
formData.attachmentUrls = ''
formData.remark = ''
}
async function handleAdd() {
resetForm()
isEdit.value = false
dialogTitle.value = '新增申请'
const today = new Date().toISOString().slice(0, 10)
formData.applyDate = today
formData.warehouseInDate = today
formData.applicantId = currentUserId.value
await loadUserOptions()
dialogVisible.value = true
}
async function handleEdit(row: VehicleUreaApply) {
isEdit.value = true
dialogTitle.value = '编辑申请'
await loadUserOptions()
Object.assign(formData, {
id: row.id,
applyNo: row.applyNo,
applicantId: row.applicantId || '',
licensePlate: row.licensePlate,
applyDate: row.applyDate,
warehouseInDate: row.warehouseInDate,
unit: row.unit || '吨桶',
quantity: Number(row.quantity),
applyAmount: Number(row.applyAmount),
attachmentUrls: row.attachmentUrls || '',
remark: row.remark || ''
})
dialogVisible.value = true
}
async function handleView(row: VehicleUreaApply) {
viewVisible.value = true
viewLoading.value = true
try {
const res = await vehicleUreaApplyApi.get(row.id)
viewDetail.value = res.data ?? res
} finally {
viewLoading.value = false
}
}
async function handleSubmit() {
await formRef.value?.validate()
if (formData.applyDate && formData.warehouseInDate && formData.applyDate > formData.warehouseInDate) {
ElMessage.warning('申请时间不能晚于入库时间')
return
}
if (attachmentUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
applicantId: formData.applicantId,
licensePlate: formData.licensePlate,
applyDate: formData.applyDate,
warehouseInDate: formData.warehouseInDate,
unit: formData.unit,
quantity: formData.quantity,
applyAmount: formData.applyAmount,
attachmentUrls: formData.attachmentUrls || undefined,
remark: formData.remark || undefined
}
if (isEdit.value) {
await vehicleUreaApplyApi.update(formData.id, payload)
ElMessage.success('更新成功')
} else {
await vehicleUreaApplyApi.create(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} finally {
submitLoading.value = false
}
}
async function handleCancel(row: VehicleUreaApply) {
await ElMessageBox.confirm('确定作废该申请吗?', '提示', { type: 'warning' })
cancelLoadingMap[row.id] = true
try {
await vehicleUreaApplyApi.cancel(row.id)
ElMessage.success('已作废')
fetchData()
} finally {
cancelLoadingMap[row.id] = false
}
}
async function handleDelete(row: VehicleUreaApply) {
await ElMessageBox.confirm('确定删除该申请吗?', '提示', { type: 'warning' })
deleteLoadingMap[row.id] = true
try {
await vehicleUreaApplyApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} finally {
deleteLoadingMap[row.id] = false
}
}
onMounted(async () => {
await Promise.all([loadVehicleOptions(), loadUserOptions()])
fetchData()
})
</script>
@@ -0,0 +1,573 @@
<!-- 车辆尿素发放登记 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">车辆尿素发放登记</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="申请单号/车牌/发放单号">
<el-input v-model="searchForm.q" placeholder="搜索关键词" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="申请状态">
<el-select v-model="searchForm.applyStatus" placeholder="全部" clearable>
<el-option label="待发放" value="pending" />
<el-option label="已发放" value="disbursed" />
<el-option label="已作废" value="cancelled" />
</el-select>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="applyDateRange"
type="daterange"
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="发放日期">
<el-date-picker
v-model="disburseDateRange"
type="daterange"
range-separator="-"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions">
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">共 <span class="sel-num">{{ pagination.total }}</span> 条申请</div>
<el-button @click="handleSupplement">
<el-icon><Plus /></el-icon>无申请补录
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="tableData" v-loading="loading" stripe row-key="applyId">
<el-table-column prop="applyNo" label="申请单号" min-width="170" show-overflow-tooltip fixed="left" />
<el-table-column prop="applyDate" label="申请时间" min-width="120" align="center" />
<el-table-column prop="warehouseInDate" label="入库时间" min-width="120" align="center" />
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" />
<el-table-column prop="unit" label="单位" min-width="90" align="center" />
<el-table-column prop="quantity" label="入库数" min-width="100" align="right">
<template #default="{ row }">{{ Number(row.quantity).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="applyAmount" label="申请金额(元)" min-width="120" align="right">
<template #default="{ row }">{{ Number(row.applyAmount).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="applyStatus" label="申请状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPlUreaApplyStatusTagType(row.applyStatus)" size="small">
{{ getPlUreaApplyStatusLabel(row.applyStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="licensePlate" label="车牌号" min-width="110" align="center" />
<el-table-column prop="disburseNo" label="发放单号" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ row.disburseNo || '—' }}</template>
</el-table-column>
<el-table-column prop="disburseDate" label="发放时间" min-width="120" align="center">
<template #default="{ row }">{{ row.disburseDate || '—' }}</template>
</el-table-column>
<el-table-column prop="amount" label="发放金额(元)" min-width="120" align="right">
<template #default="{ row }">
{{ row.amount != null ? Number(row.amount).toFixed(2) : '—' }}
</template>
</el-table-column>
<el-table-column prop="handlerName" label="处理人" min-width="100" align="center">
<template #default="{ row }">{{ row.handlerName || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="300" fixed="right" align="center" class-name="col-actions">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="row.applyStatus === 'pending'"
link
type="primary"
@click="handleDisburse(row)"
>
<el-icon><Money /></el-icon>发放
</el-button>
<template v-if="row.applyStatus === 'disbursed' && row.disburseId">
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
link
type="danger"
:loading="deleteLoadingMap[row.disburseId]"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>撤销发放
</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</section>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="720px"
align-center
class="dialog-form-layout order-module-dialog-mask"
destroy-on-close
@closed="resetForm"
>
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="top" scroll-to-error>
<div v-if="dialogMode === 'disburse' && currentApplyRow" class="apply-summary-block">
<div class="apply-summary-title">关联申请</div>
<div class="apply-summary-grid">
<div><span class="label">申请单号</span>{{ currentApplyRow.applyNo }}</div>
<div><span class="label">申请时间</span>{{ currentApplyRow.applyDate }}</div>
<div><span class="label">车牌号</span>{{ currentApplyRow.licensePlate }}</div>
<div><span class="label">入库时间</span>{{ currentApplyRow.warehouseInDate }}</div>
<div><span class="label">单位</span>{{ currentApplyRow.unit }}</div>
<div><span class="label">入库数</span>{{ Number(currentApplyRow.quantity).toFixed(2) }}</div>
<div><span class="label">申请人</span>{{ currentApplyRow.applicantName || '—' }}</div>
<div>
<span class="label">申请金额</span>
{{ Number(currentApplyRow.applyAmount).toFixed(2) }} 元
</div>
</div>
</div>
<div class="form-grid-2">
<el-form-item label="发放单号">
<el-input :model-value="formData.disburseNo || '保存后自动生成'" disabled />
</el-form-item>
<el-form-item label="处理人" prop="handlerId">
<el-select
v-model="formData.handlerId"
filterable
placeholder="请选择处理人"
style="width: 100%"
>
<el-option
v-for="user in userOptions"
:key="user.userId"
:label="user.displayName"
:value="user.userId"
/>
</el-select>
</el-form-item>
<template v-if="dialogMode === 'supplement'">
<el-form-item label="车牌号" prop="licensePlate">
<el-select
v-model="formData.licensePlate"
filterable
placeholder="请选择车牌"
style="width: 100%"
>
<el-option
v-for="item in vehicleOptions"
:key="item.id"
:label="item.licensePlate"
:value="item.licensePlate"
/>
</el-select>
</el-form-item>
</template>
<el-form-item label="发放时间" prop="disburseDate">
<el-date-picker v-model="formData.disburseDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="发放金额(元)" prop="amount">
<el-input-number v-model="formData.amount" :min="0.01" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="附件" class="form-col-span-2">
<AttachmentUploadField
ref="attachmentUploadRef"
v-model="formData.attachmentUrls"
folder="pl/vehicle-urea/disburse"
accept="image/*,.pdf"
:limit="5"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="备注" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.applyNo || '发放详情'"
:sections="viewSections"
width="760px"
modal-class="order-module-dialog-mask"
append-to-body
/>
</div>
</template>
<script setup lang="ts">
import { Check, Close, Delete, Edit, Money, Plus, RefreshLeft, Search, View } from '@element-plus/icons-vue'
import {
vehicleApi,
vehicleUreaDisburseApi,
type Vehicle,
type VehicleUreaDisburseBoardItem
} from '@/api/pl'
import { fetchUserOptions } from '@/api/admin'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { useUserStore } from '@/store/modules/user'
import { getPlUreaApplyStatusLabel, getPlUreaApplyStatusTagType } from '@/utils/pl-display'
type DialogMode = 'disburse' | 'edit' | 'supplement'
const userStore = useUserStore()
const currentUserId = computed(() => {
const info = userStore.info as Record<string, unknown> | undefined
return String(info?.id || info?.userId || '')
})
const userOptions = ref<
Array<{ userId: string; nickName: string; displayName: string }>
>([])
const loading = ref(false)
const submitLoading = ref(false)
const deleteLoadingMap = reactive<Record<string, boolean>>({})
const tableData = ref<VehicleUreaDisburseBoardItem[]>([])
const vehicleOptions = ref<Vehicle[]>([])
const applyDateRange = ref<string[]>([])
const disburseDateRange = ref<string[]>([])
const searchForm = reactive({
q: '',
applyStatus: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogMode = ref<DialogMode>('disburse')
const currentApplyRow = ref<VehicleUreaDisburseBoardItem | null>(null)
const formRef = ref()
const attachmentUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const formData = reactive({
id: '',
disburseNo: '',
applyId: '',
licensePlate: '',
handlerId: '',
disburseDate: '',
amount: null as number | null,
attachmentUrls: '',
remark: ''
})
const formRules = computed(() => ({
licensePlate:
dialogMode.value === 'supplement'
? [{ required: true, message: '请选择车牌号', trigger: 'change' }]
: [],
handlerId: [{ required: true, message: '请选择处理人', trigger: 'change' }],
disburseDate: [{ required: true, message: '请选择发放时间', trigger: 'change' }],
amount: [{ required: true, message: '请输入发放金额', trigger: 'blur' }]
}))
const viewVisible = ref(false)
const viewRow = ref<VehicleUreaDisburseBoardItem | null>(null)
const viewSections = computed<ViewSection[]>(() => {
const row = viewRow.value
if (!row) return []
const sections: ViewSection[] = [
{
title: '申请信息',
fields: [
{ label: '申请单号', value: row.applyNo },
{ label: '申请时间', value: row.applyDate },
{ label: '入库时间', value: row.warehouseInDate },
{
label: '申请状态',
value: getPlUreaApplyStatusLabel(row.applyStatus),
tagType: getPlUreaApplyStatusTagType(row.applyStatus)
},
{ label: '车牌号', value: row.licensePlate },
{ label: '单位', value: row.unit },
{ label: '入库数', value: Number(row.quantity).toFixed(2) },
{ label: '申请金额', value: `${Number(row.applyAmount).toFixed(2)} 元` },
{ label: '申请人', value: row.applicantName || '—' },
{ label: '申请备注', value: row.applyRemark || '—', span: 2 }
]
}
]
if (row.disburseId) {
sections.push({
title: '发放信息',
fields: [
{ label: '发放单号', value: row.disburseNo || '—' },
{ label: '发放时间', value: row.disburseDate || '—' },
{
label: '发放金额',
value: row.amount != null ? `${Number(row.amount).toFixed(2)} 元` : '—'
},
{ label: '处理人', value: row.handlerName || '—' },
{ label: '发放备注', value: row.disburseRemark || '—', span: 2 }
]
})
}
return sections
})
async function loadUserOptions() {
try {
const res = await fetchUserOptions()
const data: unknown = (res as { data?: unknown })?.data ?? res ?? []
userOptions.value = (Array.isArray(data) ? data : []).map((u: Record<string, unknown>) => {
const name = String(u.label || u.nickName || '')
const account = String(u.userNo || '')
return {
userId: String(u.value ?? u.id ?? ''),
nickName: name,
displayName: account ? `${name}(${account})` : name
}
})
} catch {
userOptions.value = []
}
}
async function loadVehicleOptions() {
const vehicles = await vehicleApi.list()
vehicleOptions.value = (vehicles.data ?? vehicles) || []
}
async function fetchData() {
loading.value = true
try {
const res = await vehicleUreaDisburseApi.disburseBoard({
q: searchForm.q || undefined,
applyStatus: searchForm.applyStatus || undefined,
applyDateFrom: applyDateRange.value?.[0],
applyDateTo: applyDateRange.value?.[1],
disburseDateFrom: disburseDateRange.value?.[0],
disburseDateTo: disburseDateRange.value?.[1],
page: pagination.page,
pageSize: pagination.pageSize
})
const data = res.data ?? res
tableData.value = data.items || []
pagination.total = data.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
searchForm.q = ''
searchForm.applyStatus = ''
applyDateRange.value = []
disburseDateRange.value = []
handleSearch()
}
function resetForm() {
formData.id = ''
formData.disburseNo = ''
formData.applyId = ''
formData.licensePlate = ''
formData.handlerId = ''
formData.disburseDate = ''
formData.amount = null
formData.attachmentUrls = ''
formData.remark = ''
currentApplyRow.value = null
}
async function handleDisburse(row: VehicleUreaDisburseBoardItem) {
resetForm()
dialogMode.value = 'disburse'
dialogTitle.value = '发放登记'
currentApplyRow.value = row
formData.applyId = row.applyId
formData.licensePlate = row.licensePlate
formData.amount = Number(row.applyAmount)
formData.disburseDate = new Date().toISOString().slice(0, 10)
formData.handlerId = currentUserId.value
await loadUserOptions()
dialogVisible.value = true
}
async function handleSupplement() {
resetForm()
dialogMode.value = 'supplement'
dialogTitle.value = '无申请补录'
formData.disburseDate = new Date().toISOString().slice(0, 10)
formData.handlerId = currentUserId.value
await Promise.all([loadVehicleOptions(), loadUserOptions()])
dialogVisible.value = true
}
async function handleEdit(row: VehicleUreaDisburseBoardItem) {
if (!row.disburseId) return
resetForm()
dialogMode.value = 'edit'
dialogTitle.value = '编辑发放登记'
currentApplyRow.value = row
await loadUserOptions()
const res = await vehicleUreaDisburseApi.get(row.disburseId)
const detail = res.data ?? res
Object.assign(formData, {
id: detail.id,
disburseNo: detail.disburseNo,
applyId: detail.applyId || row.applyId,
licensePlate: detail.licensePlate || row.licensePlate,
handlerId: detail.handlerId,
disburseDate: detail.disburseDate,
amount: Number(detail.amount),
attachmentUrls: detail.attachmentUrls || '',
remark: detail.remark || ''
})
dialogVisible.value = true
}
function handleView(row: VehicleUreaDisburseBoardItem) {
viewRow.value = row
viewVisible.value = true
}
async function handleSubmit() {
await formRef.value?.validate()
if (attachmentUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
applyId: formData.applyId || undefined,
licensePlate: dialogMode.value === 'supplement' ? formData.licensePlate : undefined,
handlerId: formData.handlerId,
disburseDate: formData.disburseDate,
amount: formData.amount,
attachmentUrls: formData.attachmentUrls || undefined,
remark: formData.remark || undefined
}
if (dialogMode.value === 'edit') {
await vehicleUreaDisburseApi.update(formData.id, payload)
ElMessage.success('更新成功')
} else {
await vehicleUreaDisburseApi.create(payload)
ElMessage.success('发放登记成功')
}
dialogVisible.value = false
fetchData()
} finally {
submitLoading.value = false
}
}
async function handleDelete(row: VehicleUreaDisburseBoardItem) {
if (!row.disburseId) return
await ElMessageBox.confirm('确定撤销该发放登记吗?关联申请将回退为待发放。', '提示', { type: 'warning' })
deleteLoadingMap[row.disburseId] = true
try {
await vehicleUreaDisburseApi.delete(row.disburseId)
ElMessage.success('已撤销发放')
fetchData()
} finally {
deleteLoadingMap[row.disburseId] = false
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
.apply-summary-block {
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--primary-bg);
}
.apply-summary-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.apply-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
font-size: 13px;
color: var(--text);
.label {
display: inline-block;
min-width: 72px;
margin-right: 6px;
color: var(--text-muted);
}
}
</style>
+34 -21
View File
@@ -126,7 +126,7 @@
<el-button link type="primary" @click.stop="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click.stop="handleDelete(row)">
<el-button link type="danger" :disabled="!canDeleteByCreator(row)" :title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP" @click.stop="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
@@ -167,7 +167,7 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item v-if="formData.vehicleType === 'carrier'" label="承运商" prop="carrierId">
<el-form-item label="承运商" prop="carrierId">
<el-select v-model="formData.carrierId" placeholder="请选择承运商" clearable filterable>
<el-option
v-for="c in carrierList"
@@ -259,10 +259,12 @@
</el-table>
</div>
<!-- 车辆证书(卡片) -->
<PlCertFormList
<!-- 车辆证书(主车/挂车 Tab) -->
<PlVehicleCertPartTabs
ref="certFormListRef"
v-model="certList"
:license-plate="formData.licensePlate"
:trailer-no="formData.trailerNo"
title="车辆证书"
upload-folder="pl/vehicle-cert"
:cert-name-options="certNameOptions"
@@ -294,6 +296,8 @@
<template #vehicleDetailTabs>
<PlVehicleCertPersonnelTabs
:cert-list="viewDetail?.certList || []"
:license-plate="viewDetail?.licensePlate"
:trailer-no="viewDetail?.trailerNo"
:personnel-list="viewPersonnelList"
/>
</template>
@@ -314,7 +318,7 @@ import {
type Carrier,
type Personnel
} from '@/api/pl'
import PlCertFormList from '@/components/business/pl-cert-form-list/index.vue'
import PlVehicleCertPartTabs from '@/components/business/pl-vehicle-cert-part-tabs/index.vue'
import PlVehicleCertPersonnelTabs from '@/components/business/pl-vehicle-cert-personnel-tabs/index.vue'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import SystemViewDialog, {
@@ -322,9 +326,15 @@ import SystemViewDialog, {
} from '@/components/business/system-view-dialog/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { formatDateTime } from '@/utils/format'
import {
PL_VEHICLE_CERT_NAME_OPTIONS,
splitVehicleCertsByPart,
validateVehicleRequiredCerts
} from '@/utils/pl-vehicle-cert'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
type CertListItem = CertRow & { _key?: string }
type CertFormListRef = InstanceType<typeof PlCertFormList>
type CertFormListRef = InstanceType<typeof PlVehicleCertPartTabs>
const loading = ref(false)
const submitLoading = ref(false)
@@ -346,14 +356,7 @@ const viewLoading = ref(false)
const viewDetail = ref<VehicleDetail | null>(null)
const viewPersonnelList = ref<VehiclePersonnelViewRow[]>([])
const certNameOptions = [
'行驶证',
'道路运输证',
'驾驶证',
'押运员证',
'危化品从业资格证',
'保险单'
]
const certNameOptions = PL_VEHICLE_CERT_NAME_OPTIONS
const searchForm = reactive({
q: '',
@@ -597,13 +600,17 @@ function validateSubTables(): boolean {
return false
}
}
for (const cert of certList.value) {
if (!cert.certName?.trim()) {
ElMessage.warning('证书名称不能为空')
return false
}
}
return true
const hasTrailer = Boolean(formData.trailerNo?.trim())
const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList.value)
return validateVehicleRequiredCerts({
headCerts,
trailerCerts,
hasTrailer,
licensePlate: formData.licensePlate,
trailerNo: formData.trailerNo,
onError: message => ElMessage.warning(message)
})
}
async function fetchData() {
@@ -720,6 +727,7 @@ async function handleEdit(row: Vehicle) {
certList.value = (detail.certList || []).map(c => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
vehiclePart: c.vehiclePart,
certName: c.certName,
certNo: c.certNo,
expireDate: c.expireDate,
@@ -769,6 +777,7 @@ async function handleSubmit() {
})),
certList: certList.value.map(c => ({
certId: c.certId,
vehiclePart: c.vehiclePart,
certName: c.certName.trim(),
certNo: c.certNo || undefined,
expireDate: c.expireDate || undefined,
@@ -796,6 +805,10 @@ function handleDialogClose() {
}
async function handleDelete(row: Vehicle) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm('确定删除该车辆吗?', '提示', { type: 'warning' })
await vehicleApi.delete(row.id)
ElMessage.success('删除成功')
+8 -2
View File
@@ -126,7 +126,7 @@
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-dropdown-item command="delete" divided :disabled="!canDeleteByCreator(row)">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -256,6 +256,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -702,7 +703,12 @@ const dictStore = useDictStore()
}
}
async function handleDelete(row: Api.Dept.DeptItem) {
async function handleDelete(row: Api.Dept.DeptItem)
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
{
try {
const childCount = row.children?.length || 0
const confirmText = childCount
+32 -5
View File
@@ -39,7 +39,8 @@
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleBatchDelete"
>
批量删除
@@ -118,7 +119,7 @@
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-dropdown-item command="delete" divided :disabled="!canDeleteByCreator(row)">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -346,7 +347,14 @@
<el-button class="btn-op-item primary" @click="handleEditDictData(row)">
编辑
</el-button>
<el-button class="btn-op-item" @click="handleDeleteDictData(row)"> 删除 </el-button>
<el-button
class="btn-op-item"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDeleteDictData(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -449,6 +457,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -635,7 +644,12 @@
}
}
async function handleDelete(row: Api.Dict.DictTypeItem) {
async function handleDelete(row: Api.Dict.DictTypeItem)
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
{
await ElMessageBox.confirm(`确定删除字典类型「${row.dictName}」吗?`, '提示', {
type: 'warning'
})
@@ -644,7 +658,16 @@
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
const deletable = filterDeletableByCreator(selectedRows.value)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < selectedRows.value.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
const ids = deletable.map((r) => r.id)
if (!ids.length) {
ElMessage.warning('请选择要删除的字典类型')
return
@@ -767,6 +790,10 @@
}
async function handleDeleteDictData(row: Api.Dict.DictDataItem) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm(`确定删除字典数据「${row.dictLabel}」吗?`, '提示', {
type: 'warning'
})
+13 -3
View File
@@ -123,7 +123,12 @@
<el-button type="success" link size="small" @click="handleSubmit(row.id)"
>提交审批</el-button
>
<el-button type="danger" link size="small" @click="handleDelete(row.id)"
<el-button
v-if="canDeleteByCreator(row)"
type="danger"
link
size="small"
@click="handleDelete(row)"
>删除</el-button
>
</template>
@@ -237,6 +242,7 @@
<script setup lang="ts">
import { Close, Check, RefreshLeft, Search } from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
fetchLeaveList,
fetchCreateLeave,
@@ -365,10 +371,14 @@
}
}
async function handleDelete(id: string) {
async function handleDelete(row: { id: string }) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
try {
await ElMessageBox.confirm('确定删除此请假申请?', '提示', { type: 'warning' })
await fetchDeleteLeave(id)
await fetchDeleteLeave(row.id)
loadData()
} catch {
// cancelled or error
+8 -2
View File
@@ -161,7 +161,7 @@
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-dropdown-item command="delete" divided :disabled="!canDeleteByCreator(row)">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -267,6 +267,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -536,7 +537,12 @@
}
}
async function handleDelete(row: Api.Menu.MenuItem) {
async function handleDelete(row: Api.Menu.MenuItem)
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
{
try {
const childCount = row.children?.length || 0
const confirmText = childCount
@@ -0,0 +1,286 @@
<!-- 操作日志 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">操作日志</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="操作模块">
<el-input v-model="searchForm.title" placeholder="模块名称" clearable />
</el-form-item>
<el-form-item label="操作类型">
<el-select v-model="searchForm.businessType" placeholder="全部" clearable>
<el-option
v-for="opt in OPER_LOG_BUSINESS_TYPE_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="操作人员">
<el-input v-model="searchForm.operName" placeholder="姓名或账号" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable>
<el-option label="成功" :value="0" />
<el-option label="失败" :value="1" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按模块、类型、操作人筛选操作日志。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="tableData" stripe v-loading="loading" row-key="id">
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="title" label="操作模块" min-width="140" show-overflow-tooltip />
<el-table-column label="操作类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="getOperLogBusinessTypeTagType(row.businessType)" size="small">
{{ getOperLogBusinessTypeLabel(row.businessType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="operSummary"
label="操作摘要"
min-width="180"
show-overflow-tooltip
/>
<el-table-column prop="operName" label="操作人员" min-width="120" align="center" />
<el-table-column prop="operTime" label="操作时间" min-width="170" align="center" />
<el-table-column label="状态" min-width="90" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.status === 0 ? 'success' : 'danger'" size="small">
{{ row.status === 0 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="costTime" label="耗时(ms)" min-width="100" align="center" />
<el-table-column
label="操作"
fixed="right"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDetail(row)">
<el-icon><View /></el-icon>详情
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</div>
</section>
<el-dialog
v-model="detailVisible"
title="操作日志详情"
width="720px"
align-center
class="dialog-form-layout operlog-detail-dialog"
:close-on-click-modal="false"
>
<div v-if="detailRow" class="operlog-detail-body">
<div class="detail-kv-grid">
<div class="detail-kv-item"><span class="label">操作模块</span><span>{{ detailRow.title }}</span></div>
<div class="detail-kv-item">
<span class="label">操作类型</span>
<el-tag :type="getOperLogBusinessTypeTagType(detailRow.businessType)" size="small">
{{ getOperLogBusinessTypeLabel(detailRow.businessType) }}
</el-tag>
</div>
<div class="detail-kv-item"><span class="label">操作人员</span><span>{{ detailRow.operName || '—' }}</span></div>
<div class="detail-kv-item"><span class="label">操作时间</span><span>{{ detailRow.operTime || '—' }}</span></div>
<div class="detail-kv-item full"><span class="label">操作摘要</span><span>{{ detailRow.operSummary || '—' }}</span></div>
<div class="detail-kv-item full"><span class="label">请求地址</span><span>{{ detailRow.operUrl || '—' }}</span></div>
<div class="detail-kv-item"><span class="label">操作 IP</span><span>{{ detailRow.operIp || '—' }}</span></div>
<div class="detail-kv-item"><span class="label">耗时</span><span>{{ detailRow.costTime ?? '—' }} ms</span></div>
</div>
<div class="detail-block">
<div class="detail-block-title">请求参数</div>
<pre class="detail-pre">{{ detailRow.operParam || '无' }}</pre>
</div>
<div class="detail-block">
<div class="detail-block-title">返回参数</div>
<pre class="detail-pre">{{ detailRow.jsonResult || '无' }}</pre>
</div>
<div v-if="detailRow.status === 1" class="detail-block">
<div class="detail-block-title">错误信息</div>
<pre class="detail-pre error">{{ detailRow.errorMsg || '—' }}</pre>
</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { Close, RefreshLeft, Search, View } from '@element-plus/icons-vue'
import { list as listOperLog, detail as getOperLogDetail } from '@/api/monitor/operlog'
import type { OperLogVO, OperLogQuery } from '@/api/monitor/operlog/types'
import {
OPER_LOG_BUSINESS_TYPE_OPTIONS,
getOperLogBusinessTypeLabel,
getOperLogBusinessTypeTagType
} from '@/api/monitor/operlog/types'
const loading = ref(false)
const tableData = ref<OperLogVO[]>([])
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const searchForm = reactive({
title: '',
businessType: undefined as number | undefined,
operName: '',
status: undefined as number | undefined
})
const detailVisible = ref(false)
const detailRow = ref<OperLogVO | null>(null)
function buildQuery(): OperLogQuery {
return {
pageNum: pagination.page,
pageSize: pagination.pageSize,
title: searchForm.title || undefined,
businessType: searchForm.businessType,
operName: searchForm.operName || undefined,
status: searchForm.status
}
}
async function fetchList() {
loading.value = true
try {
const res = await listOperLog(buildQuery())
const payload = res as { rows?: OperLogVO[]; list?: OperLogVO[]; total?: number }
tableData.value = payload?.rows || payload?.list || []
pagination.total = payload?.total ?? tableData.value.length
} catch (e) {
console.error(e)
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchList()
}
function handleReset() {
searchForm.title = ''
searchForm.businessType = undefined
searchForm.operName = ''
searchForm.status = undefined
handleSearch()
}
async function openDetail(row: OperLogVO) {
try {
const res = await getOperLogDetail({ id: String(row.id) })
detailRow.value = (res as OperLogVO) || row
} catch {
detailRow.value = row
}
detailVisible.value = true
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="scss">
.detail-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
margin-bottom: 16px;
}
.detail-kv-item {
font-size: 14px;
color: var(--text, #1e293b);
.label {
display: block;
font-size: 12px;
color: var(--text-muted, #64748b);
margin-bottom: 4px;
}
&.full {
grid-column: 1 / -1;
}
}
.detail-block {
margin-top: 12px;
}
.detail-block-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.detail-pre {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
font-size: 12px;
line-height: 1.5;
max-height: 240px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
&.error {
color: #dc2626;
}
}
</style>
+20 -4
View File
@@ -40,7 +40,8 @@
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleBatchDelete"
>
批量删除
@@ -126,7 +127,7 @@
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-dropdown-item command="delete" divided :disabled="!canDeleteByCreator(row)">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -317,6 +318,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -708,7 +710,12 @@ const dictStore = useDictStore()
}
}
async function handleDelete(row: Api.Post.PostItem) {
async function handleDelete(row: Api.Post.PostItem)
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
{
await ElMessageBox.confirm(`确定删除职位「${row.postName}」吗?`, '提示', { type: 'warning' })
await fetchDeletePost(row.id)
ElMessage.success('删除成功')
@@ -716,7 +723,16 @@ const dictStore = useDictStore()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
const deletable = filterDeletableByCreator(selectedRows.value)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < selectedRows.value.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
const ids = deletable.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个职位吗?`, '提示', {
type: 'warning'
+20 -4
View File
@@ -40,7 +40,8 @@
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleBatchDelete"
>
批量删除
@@ -132,7 +133,7 @@
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-dropdown-item command="delete" divided :disabled="!canDeleteByCreator(row)">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -422,6 +423,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -753,14 +755,28 @@ const dictStore = useDictStore()
}
}
async function handleDelete(row: Api.Role.RoleItem) {
async function handleDelete(row: Api.Role.RoleItem)
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
{
await ElMessageBox.confirm(`确定删除角色「${row.roleName}」吗?`, '提示', { type: 'warning' })
await fetchDeleteRole(row.id)
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
const deletable = filterDeletableByCreator(selectedRows.value)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < selectedRows.value.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
const ids = deletable.map((r) => r.id)
if (!ids.length) {
ElMessage.warning('请至少选择一项')
return
+20 -4
View File
@@ -40,7 +40,8 @@
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleBatchDelete"
>
批量删除
@@ -155,7 +156,7 @@
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-dropdown-item command="delete" divided :disabled="!canDeleteByCreator(row)">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -299,6 +300,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, Edit, Delete, More, Location, RefreshLeft, Search } from '@element-plus/icons-vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
@@ -648,7 +650,12 @@ const dictStore = useDictStore()
}
}
async function handleDelete(row: Api.Site.SiteItem) {
async function handleDelete(row: Api.Site.SiteItem)
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
{
await ElMessageBox.confirm(`确定删除站点「${row.siteName}」吗?`, '提示', { type: 'warning' })
await fetchDeleteSite(row.id)
ElMessage.success('删除成功')
@@ -656,7 +663,16 @@ const dictStore = useDictStore()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
const deletable = filterDeletableByCreator(selectedRows.value)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < selectedRows.value.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
const ids = deletable.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个站点吗?`, '提示', {
type: 'warning'
+18 -3
View File
@@ -40,7 +40,8 @@
<el-button
class="btn-ghost"
type="danger"
:disabled="deletableSelection.length === 0"
:disabled="deletableSelection.length === 0 || !allRowsDeletableByCreator(deletableSelection)"
:title="allRowsDeletableByCreator(deletableSelection) ? '' : CREATOR_DELETE_TIP"
@click="handleBatchDelete"
>
批量删除
@@ -141,7 +142,7 @@
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided :disabled="isDefaultTenantRow(row)">
<el-dropdown-item command="delete" divided :disabled="isDefaultTenantRow(row) || !canDeleteByCreator(row)">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -229,6 +230,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -424,6 +426,10 @@
}
async function handleDelete(row: Api.Tenant.TenantItem) {
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (isDefaultTenantRow(row)) {
ElMessage.warning('默认租户不可删除')
return
@@ -434,7 +440,16 @@
}
async function handleBatchDelete() {
const ids = deletableSelection.value.map((r) => r.id)
const rows = filterDeletableByCreator(deletableSelection.value)
if (!rows.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (rows.length < deletableSelection.value.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
const ids = rows.map((r) => r.id)
if (!ids.length) {
ElMessage.warning('所选中含默认租户或无可删项,请重新选择')
return
+13 -2
View File
@@ -93,7 +93,8 @@
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
@@ -294,6 +295,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -569,7 +571,16 @@ const dictStore = useDictStore()
}
async function handleBatchDelete() {
const rows = selectedRows.value
const deletable = filterDeletableByCreator(selectedRows.value)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < selectedRows.value.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
const rows = deletable
if (!rows.length) {
ElMessage.warning('请至少选择一项')
return
@@ -45,7 +45,8 @@
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
@@ -129,7 +130,7 @@
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-dropdown-item command="delete" divided :disabled="!canDeleteByCreator(row)">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -208,6 +209,7 @@
<script setup lang="ts">
import {
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
Plus,
Close,
Check,
@@ -358,14 +360,28 @@
}
}
async function handleDelete(row: Api.Workflow.ProcessDefItem) {
async function handleDelete(row: Api.Workflow.ProcessDefItem)
if (!canDeleteByCreator(row)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
{
await ElMessageBox.confirm('确定要删除该流程吗?', '提示', { type: 'warning' })
await fetchDeleteProcessDef(row.id)
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
const deletable = filterDeletableByCreator(selectedRows.value)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < selectedRows.value.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
const ids = deletable.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个流程吗?`, '提示', {
type: 'warning'