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

871 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="price-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">SERVICE PRICE</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.priceMode" placeholder="全部方式" allow-clear>
<a-select-option :value="1">按次</a-select-option>
<a-select-option :value="2">按工时</a-select-option>
<a-select-option :value="3">材料</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">服务类型</label>
<a-select v-model:value="searchForm.serviceType" 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.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 === 'priceName'">
<span class="price-name">{{ record.priceName }}</span>
</template>
<template v-else-if="column.key === 'serviceType'">
{{ record.serviceType || '-' }}
</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">Price Information</span>
</div>
</div>
<a-form-item label="价目名称" name="priceName">
<a-input v-model:value="formData.priceName" placeholder="请输入价目名称" maxlength="64" />
</a-form-item>
<a-form-item label="服务类型" name="serviceType">
<a-select v-model:value="formData.serviceType" placeholder="请选择服务类型" allow-clear 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="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-option :value="3">材料</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 {
servicePriceApi,
type ServicePriceInfo,
type ServicePriceQuery,
} from '@/api/servicePrice';
import { useTableScroll } from '@/composables/useTableScroll';
import { usePermissionStore } from '@/stores/permission';
const { wrapperRef, tableHeight } = useTableScroll();
const permissionStore = usePermissionStore();
// 按钮权限
const canCreate = computed(() => permissionStore.hasPermission('price:create'));
const canUpdate = computed(() => permissionStore.hasPermission('price:update'));
const canDelete = computed(() => permissionStore.hasPermission('price:delete'));
// 计价方式 → 默认单位
const PRICE_MODE_UNIT: Record<number, string> = { 1: '次', 2: '小时', 3: '个' };
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<ServicePriceInfo[]>([]);
const total = ref(0);
const searchForm = reactive<ServicePriceQuery>({
page: 1,
pageSize: 10,
keyword: '',
priceMode: undefined as any,
serviceType: undefined as any,
status: undefined as any,
});
const columns = [
{ title: '价目编码', dataIndex: 'priceCode', key: 'priceCode', ellipsis: true, width: 150 },
{ title: '价目名称', dataIndex: 'priceName', key: 'priceName', ellipsis: true },
{ title: '服务类型', dataIndex: 'serviceType', key: 'serviceType', 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({
priceName: '',
serviceType: undefined as string | undefined,
priceMode: undefined as number | undefined,
unitPrice: undefined as number | undefined,
unit: '',
remark: '',
status: 1,
});
const formRules = {
priceName: [
{ required: true, message: '请输入价目名称', trigger: 'blur' },
{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' },
],
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 servicePriceApi.getServicePriceList(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.priceMode = undefined as any;
searchForm.serviceType = 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: ServicePriceInfo) {
isEdit.value = true;
editId.value = record.id;
editCode.value = record.priceCode;
modalTitle.value = '编辑价目';
resetForm();
formData.priceName = record.priceName;
formData.serviceType = record.serviceType || undefined;
formData.priceMode = record.priceMode;
formData.unitPrice = Number(record.unitPrice);
formData.unit = record.unit || '';
formData.remark = record.remark || '';
formData.status = record.status;
// 编辑已有记录视为已手动设定单位,避免切换计价方式覆盖既有单位
unitManuallyEdited.value = true;
modalVisible.value = true;
}
function resetForm() {
formData.priceName = '';
formData.serviceType = 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 = {
priceName: formData.priceName,
serviceType: formData.serviceType || 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 servicePriceApi.updateServicePrice(editId.value, submitData);
} else {
res = await servicePriceApi.createServicePrice(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 servicePriceApi.deleteServicePrice(id);
if (res.code === 200) {
message.success('删除成功');
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
// 行内状态切换
async function handleStatusChange(record: ServicePriceInfo, checked: boolean) {
const status = checked ? 1 : 0;
try {
const res: any = await servicePriceApi.updateServicePriceStatus(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: '按工时', 3: '材料' };
return textMap[mode ?? 0] || '未知';
}
function getPriceModeColor(mode?: number): string {
const colorMap: Record<number, string> = { 1: 'blue', 2: 'orange', 3: 'green' };
return colorMap[mode ?? 0] || 'default';
}
function formatPrice(value?: number | string | null): string {
return Number(value ?? 0).toFixed(2);
}
</script>
<style scoped>
.price-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;
}
.price-name {
font-weight: 600;
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>