refactor(menu): 菜单模块归属调整 - 勘察费用/运维费定价迁至基础数据(/base/*),项目勘察/项目安装迁至运营派单(/dispatch/*)并按序排列(电工→勘察→安装→报修→维保);同步前端路由/页面git mv/通知跳转与db_seed,生产库已执行迁移

This commit is contained in:
wk committed 2026-10-11 12:21:30 +08:00
1 parent 77891bf4f2
commit 850cd92584
11 files changed
+121 -74

No files matched your search

+871
View File
@@ -0,0 +1,871 @@
<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>