Files
smart_grid_iot/client/src/views/base/cost/index.vue
T

864 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="cost-page">
<!-- 顶部标题 + 操作 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>项目管理</span>
<span class="sep">/</span>
<span class="cur">勘察费用</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">勘察费用</span>
<span class="sub-title">SURVEY FEE</span>
</div>
<p class="desc">维护勘察派单相关费用标准(基础出场费、回路测点补贴等),支持按次、按工时两种计价方式。</p>
</div>
<div class="header-actions">
<a-button v-if="canCreate" type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增勘察费用
</a-button>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="价格编码"
allow-clear
maxlength="64"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div class="filter-item">
<label class="filter-label">派单类型</label>
<a-select v-model:value="searchForm.dispatchType" placeholder="全部类型" allow-clear>
<a-select-option value="勘察">勘察</a-select-option>
<a-select-option value="安装">安装</a-select-option>
<a-select-option value="预警">预警</a-select-option>
<a-select-option value="维修">维修</a-select-option>
<a-select-option value="维保">维保</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">计价方式</label>
<a-select v-model:value="searchForm.priceMode" placeholder="全部方式" allow-clear>
<a-select-option :value="1">按次</a-select-option>
<a-select-option :value="2">按工时</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">状态</label>
<a-select v-model:value="searchForm.status" placeholder="全部状态" allow-clear>
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">停用</a-select-option>
</a-select>
</div>
</div>
<div class="filter-bottom">
<div class="filter-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">勘察费用清单</span>
<span class="count">{{ total }}</span>
</div>
<a-button type="text" size="small" title="刷新列表" @click="fetchList">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'priceCode'">
<span class="mono">{{ record.priceCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'dispatchType'">
{{ record.dispatchType || '-' }}
</template>
<template v-else-if="column.key === 'priceType'">
{{ record.priceType || '-' }}
</template>
<template v-else-if="column.key === 'priceMode'">
<a-tag :color="getPriceModeColor(record.priceMode)">{{ getPriceModeText(record.priceMode) }}</a-tag>
</template>
<template v-else-if="column.key === 'unitPrice'">
<span class="mono">{{ formatPrice(record.unitPrice) }}</span>
</template>
<template v-else-if="column.key === 'unit'">
{{ record.unit || '-' }}
</template>
<template v-else-if="column.key === 'status'">
<a-switch
:checked="record.status === 1"
checked-children="启用"
un-checked-children="停用"
@change="(checked: boolean) => handleStatusChange(record, checked)"
/>
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || '-' }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button v-if="canUpdate" type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm
v-if="canDelete"
title="确定要删除该勘察费用吗?"
ok-text="确定"
cancel-text="取消"
ok-button-props="{ danger: true }"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无勘察费用数据" />
</template>
</a-table>
</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="640px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<!-- 价格编码信息条(编辑态只读展示) -->
<div v-if="isEdit && editId" class="no-bar">
<div class="no-item">
<span class="no-label">价格编码</span>
<span class="no-value mono">{{ editCode || '-' }}</span>
</div>
</div>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<div class="form-section">
<div class="section-head">
<span class="section-no">01</span>
<div class="section-text">
<span class="section-name">勘察费用信息</span>
<span class="section-sub">Cost Price Information</span>
</div>
</div>
<a-form-item label="派单类型" name="dispatchType">
<a-select v-model:value="formData.dispatchType" placeholder="请选择派单类型" style="width: 100%">
<a-select-option value="勘察">勘察</a-select-option>
<a-select-option value="安装">安装</a-select-option>
<a-select-option value="预警">预警</a-select-option>
<a-select-option value="维修">维修</a-select-option>
<a-select-option value="维保">维保</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="价格类型" name="priceType">
<a-select v-model:value="formData.priceType" placeholder="请选择价格类型" allow-clear style="width: 100%">
<a-select-option value="单价">单价</a-select-option>
<a-select-option value="总价提成">总价提成</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="计价方式" name="priceMode">
<a-select
v-model:value="formData.priceMode"
placeholder="请选择计价方式"
style="width: 100%"
@change="handlePriceModeChange"
>
<a-select-option :value="1">按次</a-select-option>
<a-select-option :value="2">按工时</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="单价" name="unitPrice">
<a-input-number
v-model:value="formData.unitPrice"
:min="0"
:precision="2"
placeholder="请输入单价"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="单位" name="unit">
<a-input
v-model:value="formData.unit"
placeholder="如:次 / 小时"
maxlength="16"
@change="handleUnitManualChange"
/>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="请输入备注" maxlength="255" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">停用</a-radio>
</a-radio-group>
</a-form-item>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { SearchOutlined, ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import {
dispatchPriceApi,
type DispatchPriceInfo,
type DispatchPriceQuery,
} from '@/api/dispatchPrice';
import { useTableScroll } from '@/composables/useTableScroll';
import { usePermissionStore } from '@/stores/permission';
const { wrapperRef, tableHeight } = useTableScroll();
const permissionStore = usePermissionStore();
// 按钮权限
const canCreate = computed(() => permissionStore.hasPermission('cost:create'));
const canUpdate = computed(() => permissionStore.hasPermission('cost:update'));
const canDelete = computed(() => permissionStore.hasPermission('cost:delete'));
// 计价方式 → 默认单位
const PRICE_MODE_UNIT: Record<number, string> = { 1: '次', 2: '小时' };
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<DispatchPriceInfo[]>([]);
const total = ref(0);
const searchForm = reactive<DispatchPriceQuery>({
page: 1,
pageSize: 10,
keyword: '',
dispatchType: undefined as any,
priceMode: undefined as any,
status: undefined as any,
});
const columns = [
{ title: '价格编码', dataIndex: 'priceCode', key: 'priceCode', ellipsis: true, width: 150 },
{ title: '派单类型', dataIndex: 'dispatchType', key: 'dispatchType', ellipsis: true, width: 110 },
{ title: '价格类型', dataIndex: 'priceType', key: 'priceType', ellipsis: true, width: 110 },
{ title: '计价方式', key: 'priceMode', align: 'center' as const, width: 110 },
{ title: '单价', key: 'unitPrice', align: 'right' as const, width: 110 },
{ title: '单位', dataIndex: 'unit', key: 'unit', align: 'center' as const, width: 80 },
{ title: '状态', key: 'status', align: 'center' as const, width: 100 },
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
{ title: '操作', key: 'action', width: 150, align: 'center' as const, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
// ==================== 弹窗表单 ====================
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const editCode = ref('');
// 用户是否手动修改过单位(手动修改后不再随计价方式联动覆盖)
const unitManuallyEdited = ref(false);
const formData = reactive({
dispatchType: undefined as string | undefined,
priceType: undefined as string | undefined,
priceMode: undefined as number | undefined,
unitPrice: undefined as number | undefined,
unit: '',
remark: '',
status: 1,
});
const formRules = {
dispatchType: [{ required: true, message: '请选择派单类型', trigger: 'change' }],
priceMode: [{ required: true, message: '请选择计价方式', trigger: 'change' }],
unitPrice: [{ required: true, message: '请输入单价', trigger: 'change', type: 'number' as const }],
unit: [{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' }],
remark: [{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' }],
};
// ==================== 生命周期 ====================
onMounted(() => {
fetchList();
});
// ==================== 数据请求 ====================
async function fetchList() {
loading.value = true;
try {
const res: any = await dispatchPriceApi.getDispatchPriceList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取勘察费用列表失败');
} finally {
loading.value = false;
}
}
// ==================== 搜索重置 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.dispatchType = undefined as any;
searchForm.priceMode = undefined as any;
searchForm.status = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
// ==================== 新增/编辑 ====================
function handleAdd() {
isEdit.value = false;
editId.value = null;
editCode.value = '';
modalTitle.value = '新增勘察费用';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: DispatchPriceInfo) {
isEdit.value = true;
editId.value = record.id;
editCode.value = record.priceCode;
modalTitle.value = '编辑勘察费用';
resetForm();
formData.dispatchType = record.dispatchType || undefined;
formData.priceType = record.priceType || undefined;
formData.priceMode = record.priceMode;
formData.unitPrice = Number(record.unitPrice ?? 0);
formData.unit = record.unit || '';
formData.remark = record.remark || '';
formData.status = record.status;
// 编辑已有记录视为已手动设定单位,避免切换计价方式覆盖既有单位
unitManuallyEdited.value = true;
modalVisible.value = true;
}
function resetForm() {
formData.dispatchType = undefined;
formData.priceType = undefined;
formData.priceMode = undefined;
formData.unitPrice = undefined;
formData.unit = '';
formData.remark = '';
formData.status = 1;
unitManuallyEdited.value = false;
if (formRef.value) {
formRef.value.resetFields();
}
}
// 计价方式变化:未手动改过单位时按计价方式联动单位
function handlePriceModeChange(v: any) {
if (!unitManuallyEdited.value) {
formData.unit = PRICE_MODE_UNIT[Number(v)] || '';
}
}
// 用户手动修改单位后置标记
function handleUnitManualChange() {
unitManuallyEdited.value = true;
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const submitData = {
dispatchType: formData.dispatchType,
priceType: formData.priceType || undefined,
priceMode: formData.priceMode as number,
unitPrice: formData.unitPrice as number,
unit: formData.unit || undefined,
status: formData.status,
remark: formData.remark || undefined,
};
let res: any;
if (isEdit.value && editId.value) {
res = await dispatchPriceApi.updateDispatchPrice(editId.value, submitData);
} else {
res = await dispatchPriceApi.createDispatchPrice(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await dispatchPriceApi.deleteDispatchPrice(id);
if (res.code === 200) {
message.success('删除成功');
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
// 行内状态切换
async function handleStatusChange(record: DispatchPriceInfo, checked: boolean) {
const status = checked ? 1 : 0;
try {
const res: any = await dispatchPriceApi.updateDispatchPriceStatus(record.id, status);
if (res.code === 200) {
message.success(status === 1 ? '已启用' : '已停用');
fetchList();
} else {
message.error(res.message || '状态更新失败');
}
} catch (error) {
message.error('状态更新失败');
}
}
// ==================== 辅助 ====================
function getPriceModeText(mode?: number): string {
const textMap: Record<number, string> = { 1: '按次', 2: '按工时' };
return textMap[mode ?? 0] || '未知';
}
function getPriceModeColor(mode?: number): string {
const colorMap: Record<number, string> = { 1: 'blue', 2: 'orange' };
return colorMap[mode ?? 0] || 'default';
}
function formatPrice(value?: number | string | null): string {
return Number(value ?? 0).toFixed(2);
}
</script>
<style scoped>
.cost-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
box-sizing: border-box;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
}
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
line-height: 1.6;
}
.header-actions {
flex-shrink: 0;
}
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px 16px;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.filter-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #dbe4ee;
}
.filter-actions {
display: flex;
gap: 8px;
}
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 12px;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .count {
padding: 1px 8px;
border-radius: 6px;
background: #eef4ff;
color: #0054cd;
font-size: 12px;
font-weight: 600;
font-family: Consolas, Menlo, monospace;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
/* 弹窗 */
.no-bar {
display: flex;
gap: 24px;
padding: 10px 12px;
margin-bottom: 12px;
border-radius: 8px;
background: #f4f7ff;
border: 1px solid #dbe4ee;
}
.no-item {
display: flex;
align-items: baseline;
gap: 10px;
}
.no-label {
font-size: 12px;
color: #5b6b7e;
}
.no-value {
font-size: 14px;
font-weight: 700;
color: #0054cd;
}
.form-section {
margin-bottom: 4px;
}
.section-head {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid #eef1f6;
}
.section-no {
width: 26px;
height: 26px;
border-radius: 7px;
background: #0054cd;
color: #fff;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-family: Consolas, Menlo, monospace;
flex-shrink: 0;
}
.section-text {
display: flex;
flex-direction: column;
gap: 1px;
}
.section-name {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.section-sub {
font-size: 11px;
color: #8a97a8;
letter-spacing: 0.5px;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
background: #0947ab;
border-color: #0947ab;
}
:deep(.ant-modal-title) {
color: #0d1c2c;
font-weight: 700;
}
</style>