Files
smart_grid_iot/client/src/views/dispatch/install/index.vue
T

874 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="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>