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

-873
View File
@@ -1,873 +0,0 @@
<template>
<div class="install-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">INSTALL DISPATCH LEDGER</span>
</div>
<p class="desc">统筹项目方案落地施工派单,跟踪派单、施工起止与完工状态全流程。</p>
</div>
<div class="header-actions">
<a-button 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 filter-item-search">
<label class="filter-label">模糊搜索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="输入安装单号、安装人、备注..."
allow-clear
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div class="filter-item">
<label class="filter-label">关联客户</label>
<a-select
v-model:value="searchForm.customerId"
placeholder="全部客户"
allow-clear
show-search
option-filter-prop="label"
:options="customerOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">当前状态</label>
<a-select
v-model:value="searchForm.status"
placeholder="全部状态"
allow-clear
:options="statusOptions"
/>
</div>
<div class="filter-item filter-item-date">
<label class="filter-label">派单日期范围</label>
<a-range-picker
v-model:value="searchForm.dateRange"
style="width: 100%"
:placeholder="['开始日期', '结束日期']"
/>
</div>
<div class="filter-item filter-item-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="divider">|</span>
<span class="range-text">
显示第 <b>{{ rangeStart }}</b> 到 <b>{{ rangeEnd }}</b> 笔,共
<b class="strong">{{ total }}</b> 笔派单记录
</span>
</div>
<div class="list-extra">
<a-button type="text" size="small" title="刷新列表" @click="fetchList">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="false"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'installNo'">
<span class="install-no">{{ record.installNo }}</span>
</template>
<template v-else-if="column.key === 'customer'">
<div class="cust-name">{{ record.customer?.customerName || '-' }}</div>
<div class="sub-line">{{ record.customer?.contactPhone || '—' }}</div>
</template>
<template v-else-if="column.key === 'plan'">
<div class="plan-no">{{ record.plan?.planNo || '-' }}</div>
<div class="sub-line">{{ record.plan?.planDesc || '—' }}</div>
</template>
<template v-else-if="column.key === 'installer'">
<div class="type-main">{{ record.installer || record.workerName || '-' }}</div>
</template>
<template v-else-if="column.key === 'dispatchDate'">
<div class="plan-time">{{ formatDate(record.dispatchDate) }}</div>
</template>
<template v-else-if="column.key === 'period'">
<div class="plan-time">
{{ formatDate(record.startDate) }} ~ {{ formatDate(record.endDate) }}
</div>
</template>
<template v-else-if="column.key === 'status'">
<span class="status-pill" :style="pillStyle(statusMeta(record.status))">
<i class="dot" :style="{ background: statusMeta(record.status).color }"></i>
{{ statusMeta(record.status).label }}
</span>
</template>
<template v-else-if="column.key === 'action'">
<div class="row-actions">
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button
v-if="record.status === INSTALL_STATUS.DISPATCHED"
type="link"
size="small"
@click="handleComplete(record)"
>标记完成</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">删除</a-button>
</div>
</template>
</template>
<template #emptyText>
<a-empty description="暂无安装派单数据" />
</template>
</a-table>
</div>
<!-- 表尾:分页 -->
<div class="list-footer">
<a-pagination
v-model:current="searchForm.page"
v-model:page-size="searchForm.pageSize"
:total="total"
:show-size-changer="true"
:show-quick-jumper="true"
:page-size-options="['10', '20', '50']"
:show-total="(t: number) => `共 ${t} 条`"
@change="handlePageChange"
/>
</div>
</div>
</div>
<!-- 新建/编辑弹窗 -->
<a-modal
v-model:open="formVisible"
:title="formMode === 'create' ? '新增安装派单' : '编辑安装派单'"
:confirm-loading="formLoading"
width="640px"
@ok="handleSubmit"
>
<a-form ref="formRef" :model="form" :rules="formRules" :label-col="{ span: 6 }" :wrapper-col="{ span: 17 }">
<a-form-item label="安装单号" v-if="formMode === 'edit'">
<span class="mono">{{ form.installNo }}</span>
</a-form-item>
<a-form-item label="关联客户" name="customerId">
<a-select
v-model:value="form.customerId"
placeholder="请选择客户"
show-search
option-filter-prop="label"
:options="customerOptions"
:disabled="formMode === 'edit'"
@change="onCustomerChange"
/>
</a-form-item>
<a-form-item label="关联方案" name="planId">
<a-select
v-model:value="form.planId"
placeholder="请选择方案"
show-search
option-filter-prop="label"
:options="planOptions"
:not-found-content="form.customerId ? '该客户暂无方案' : '请先选择客户'"
/>
</a-form-item>
<a-form-item label="安装人" name="installer">
<a-input v-model:value="form.installer" placeholder="请输入安装施工负责人" maxlength="64" />
</a-form-item>
<a-form-item label="派单日期" name="dispatchDate">
<a-date-picker
v-model:value="form.dispatchDate"
style="width: 100%"
placeholder="请选择派单日期"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<a-form-item label="施工起止" name="period">
<a-range-picker
v-model:value="form.period"
style="width: 100%"
:placeholder="['开始日期', '结束日期']"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="form.remark" placeholder="选填,施工说明、注意事项等" :rows="3" maxlength="500" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { message, Modal } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import dayjs from 'dayjs';
import {
installOrderApi,
INSTALL_STATUS_OPTIONS,
INSTALL_STATUS,
type InstallOrderInfo,
type InstallOrderQuery,
} from '@/api/installOrder';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { projectPlanApi, type ProjectPlanInfo } from '@/api/projectPlan';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
// ==================== 元数据 ====================
const statusOptions = INSTALL_STATUS_OPTIONS;
// 状态配色:0已派发(琥珀) 1已完成(绿)
const STATUS_META: Record<number, { label: string; color: string; bg: string; border: string }> = {
0: { label: '已派发', color: '#d97706', bg: '#fef3c7', border: '#fde68a' },
1: { label: '已完成', color: '#059669', bg: '#d1fae5', border: '#a7f3d0' },
};
const statusMeta = (v: number) => STATUS_META[v] || STATUS_META[0];
const pillStyle = (m: { color: string; bg: string; border: string }) => ({
background: m.bg,
color: m.color,
borderColor: m.border,
});
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<InstallOrderInfo[]>([]);
const total = ref(0);
const searchForm = reactive({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as number | undefined,
status: undefined as number | undefined,
dateRange: undefined as any,
});
const columns = [
{ title: '安装单号', key: 'installNo', width: 170 },
{ title: '关联客户', key: 'customer', width: 200 },
{ title: '关联方案', key: 'plan', width: 180 },
{ title: '安装人', key: 'installer', width: 130 },
{ title: '派单日期', key: 'dispatchDate', width: 130, align: 'center' as const },
{ title: '施工起止', key: 'period', width: 220 },
{ title: '状态', key: 'status', width: 120, align: 'center' as const },
{ title: '操作', key: 'action', width: 200, align: 'center' as const, fixed: 'right' as const },
];
const rangeStart = computed(() => (total.value === 0 ? 0 : (searchForm.page - 1) * searchForm.pageSize + 1));
const rangeEnd = computed(() => Math.min(searchForm.page * searchForm.pageSize, total.value));
// ==================== 下拉选项 ====================
const customerOptions = ref<{ label: string; value: number }[]>([]);
const planOptions = ref<{ label: string; value: number }[]>([]);
async function fetchCustomerOptions() {
try {
const res = await customerApi.getCustomerOptions();
customerOptions.value = (res.data || []).map((item: CustomerInfo) => ({
label: item.customerName,
value: item.id,
}));
} catch {
customerOptions.value = [];
}
}
// 按客户拉取方案下拉(方案与客户一致性校验在后端)
async function fetchPlanOptions(customerId?: number) {
if (!customerId) {
planOptions.value = [];
return;
}
try {
const res = await projectPlanApi.getList({ page: 1, pageSize: 999, customerId });
planOptions.value = (res.data?.list || []).map((item: ProjectPlanInfo) => ({
label: item.planDesc ? `${item.planNo} · ${item.planDesc}` : item.planNo,
value: item.id,
}));
} catch {
planOptions.value = [];
}
}
// ==================== 数据请求 ====================
function buildQuery(): InstallOrderQuery {
const range = searchForm.dateRange as any[] | undefined;
let dateRange: string | undefined;
if (range && range.length === 2 && (range[0] || range[1])) {
const s = range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '';
const e = range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '';
if (s || e) dateRange = `${s},${e}`;
}
return {
page: searchForm.page,
pageSize: searchForm.pageSize,
keyword: searchForm.keyword || undefined,
customerId: searchForm.customerId,
status: searchForm.status,
dateRange,
};
}
async function fetchList() {
loading.value = true;
try {
const res = await installOrderApi.getList(buildQuery());
dataSource.value = res.data.list || [];
total.value = res.data.total || 0;
} catch {
dataSource.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
// ==================== 筛选与分页 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined;
searchForm.status = undefined;
searchForm.dateRange = undefined;
searchForm.page = 1;
fetchList();
}
function handlePageChange(page: number, pageSize: number) {
searchForm.page = page;
searchForm.pageSize = pageSize;
fetchList();
}
// ==================== 新建/编辑弹窗 ====================
const formVisible = ref(false);
const formLoading = ref(false);
const formMode = ref<'create' | 'edit'>('create');
const formRef = ref();
const editingId = ref<number | null>(null);
const form = reactive({
installNo: '',
customerId: undefined as number | undefined,
planId: undefined as number | undefined,
installer: '',
dispatchDate: undefined as any,
period: undefined as any,
remark: '',
});
const formRules = {
customerId: [{ required: true, message: '请选择关联客户', trigger: 'change' }],
planId: [{ required: true, message: '请选择关联方案', trigger: 'change' }],
};
function resetForm() {
form.installNo = '';
form.customerId = undefined;
form.planId = undefined;
form.installer = '';
form.dispatchDate = undefined;
form.period = undefined;
form.remark = '';
planOptions.value = [];
}
function handleAdd() {
formMode.value = 'create';
editingId.value = null;
resetForm();
formVisible.value = true;
}
function handleEdit(record: InstallOrderInfo) {
formMode.value = 'edit';
editingId.value = record.id;
resetForm();
form.installNo = record.installNo;
form.customerId = record.customerId;
form.planId = record.planId;
form.installer = record.installer || '';
form.dispatchDate = record.dispatchDate ? dayjs(record.dispatchDate) : undefined;
form.period = (record.startDate || record.endDate)
? [record.startDate ? dayjs(record.startDate) : undefined, record.endDate ? dayjs(record.endDate) : undefined]
: undefined;
form.remark = record.remark || '';
fetchPlanOptions(record.customerId);
formVisible.value = true;
}
function onCustomerChange() {
form.planId = undefined;
fetchPlanOptions(form.customerId);
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
const period = form.period as any[] | undefined;
const payload = {
customerId: form.customerId!,
planId: form.planId!,
installer: form.installer || undefined,
dispatchDate: form.dispatchDate ? dayjs(form.dispatchDate).format('YYYY-MM-DD') : undefined,
startDate: period && period[0] ? dayjs(period[0]).format('YYYY-MM-DD') : undefined,
endDate: period && period[1] ? dayjs(period[1]).format('YYYY-MM-DD') : undefined,
remark: form.remark || undefined,
};
formLoading.value = true;
try {
if (formMode.value === 'create') {
const res = await installOrderApi.create(payload);
message.success(res.message || '创建成功');
} else if (editingId.value !== null) {
const res = await installOrderApi.update(editingId.value, payload);
message.success(res.message || '更新成功');
}
formVisible.value = false;
fetchList();
} catch {
// 拦截器已提示
} finally {
formLoading.value = false;
}
}
// ==================== 行内操作 ====================
function handleComplete(record: InstallOrderInfo) {
Modal.confirm({
title: '确认标记完成?',
content: `安装单 ${record.installNo} 将变更为「已完成」状态,完成后不可再修改。`,
okText: '确认完成',
cancelText: '取消',
async onOk() {
try {
const res = await installOrderApi.complete(record.id);
message.success(res.message || '已标记完成');
fetchList();
} catch {
// 拦截器已提示
}
},
});
}
function handleDelete(record: InstallOrderInfo) {
Modal.confirm({
title: '确认删除该安装派单?',
content: `安装单 ${record.installNo} 删除后不可恢复,请谨慎操作。`,
okText: '确认删除',
okType: 'danger',
cancelText: '返回',
async onOk() {
try {
const res = await installOrderApi.delete(record.id);
message.success(res.message || '删除成功');
fetchList();
} catch {
// 拦截器已提示
}
},
});
}
// ==================== 展示辅助 ====================
function formatDate(value?: string) {
if (!value) return '—';
return dayjs(value).format('YYYY-MM-DD');
}
</script>
<style scoped>
.install-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;
display: flex;
gap: 8px;
}
.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: minmax(220px, 2fr) 1fr 1fr 1.6fr auto;
gap: 12px 16px;
align-items: end;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.filter-item-actions {
flex-direction: row;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.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;
gap: 12px;
padding: 2px 4px 12px;
flex-wrap: wrap;
}
.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 .divider {
color: #cbd5e1;
}
.range-text {
font-size: 12px;
color: #5b6b7e;
}
.range-text b {
font-family: Consolas, Menlo, monospace;
color: #0d1c2c;
}
.range-text .strong {
color: #0054cd;
font-weight: 700;
}
.list-extra {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #5b6b7e;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.list-footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
padding-top: 12px;
margin-top: 8px;
border-top: 1px solid #eef1f6;
}
.install-no {
font-family: Consolas, Menlo, monospace;
font-weight: 700;
color: #0054cd;
}
.cust-name {
font-weight: 600;
color: #0d1c2c;
line-height: 1.4;
}
.type-main {
font-weight: 600;
color: #1e293b;
line-height: 1.4;
}
.plan-no {
font-family: Consolas, Menlo, monospace;
font-size: 12.5px;
color: #0d1c2c;
}
.sub-line {
margin-top: 3px;
font-size: 11.5px;
color: #8a97a8;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.plan-time {
font-family: Consolas, Menlo, monospace;
font-size: 12.5px;
color: #0d1c2c;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 10px;
border-radius: 9999px;
border: 1px solid transparent;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.status-pill .dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
flex-wrap: nowrap;
}
.mono {
font-family: Consolas, Menlo, monospace;
color: #0054cd;
font-weight: 600;
}
:deep(.ant-table) {
font-size: 13px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
: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;
}
</style>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff