更新项目管理模块中合同管理、项目安装、项目验收、回访记录等功能
This commit is contained in:
1 parent
307e9cf1b9
commit
b3cef22a54
32 files changed
+6094
-2600
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,873 @@
|
||||
<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
@@ -208,6 +208,18 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field span-2">
|
||||
<label class="field-label"><span>备注</span></label>
|
||||
<a-textarea
|
||||
v-model:value="formState.remark"
|
||||
:rows="3"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
placeholder="派单备注说明,如现场特殊要求、安全交底等"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,75 +1,168 @@
|
||||
<template>
|
||||
<div class="page-management">
|
||||
<a-card title="回访记录管理">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<div class="visit-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">VISIT RECORD LEDGER</span>
|
||||
</div>
|
||||
<p class="desc">登记项目交付后的客户回访记录,跟踪回访人、回访时间与客户意见反馈全流程。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增回访
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<a-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="联系人/回访人"
|
||||
allow-clear
|
||||
style="width: 220px"
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
<!-- 主体卡片 -->
|
||||
<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 filter-item-date">
|
||||
<label class="filter-label">回访日期范围</label>
|
||||
<a-range-picker
|
||||
v-model:value="visitRange"
|
||||
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="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 === 'customerName'">
|
||||
{{ getCustomerName(record.customerId) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'visitTime'">
|
||||
{{ record.visitTime || '-' }}
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm title="确定要删除该回访记录吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 列表区 -->
|
||||
<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>
|
||||
|
||||
<template #emptyText>
|
||||
<a-empty description="暂无回访记录" />
|
||||
</template>
|
||||
</a-table>
|
||||
<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 === 'customerName'">
|
||||
<div class="cust-name">{{ getCustomerName(record.customerId) }}</div>
|
||||
<div class="sub-line">{{ record.contactPerson || '—' }}{{ record.contactPhone ? ' · ' + record.contactPhone : '' }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'visitor'">
|
||||
{{ record.visitor || '—' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'visitTime'">
|
||||
<div class="plan-time">{{ record.visitTime || '—' }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'customerOpinion'">
|
||||
<div v-if="record.customerOpinion" class="sub-line">{{ record.customerOpinion }}</div>
|
||||
<div v-else>—</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<div class="row-actions">
|
||||
<a-button type="primary" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-dropdown trigger="click">
|
||||
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => handleMore(key as string, record)">
|
||||
<a-menu-item key="delete" danger>删除</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #emptyText>
|
||||
<a-empty description="暂无回访记录" />
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 表尾分页 -->
|
||||
<div class="list-footer">
|
||||
<span class="range-text">
|
||||
共 <b class="strong">{{ total }}</b> 条
|
||||
</span>
|
||||
<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>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
@@ -107,7 +200,7 @@
|
||||
<a-date-picker v-model:value="formData.visitTime" style="width: 100%" placeholder="请选择回访时间" />
|
||||
</a-form-item>
|
||||
<a-form-item label="客户意见" name="customerOpinion">
|
||||
<a-textarea v-model:value="formData.customerOpinion" :rows="3" placeholder="请输入客户意见" maxlength="2000" />
|
||||
<a-textarea v-model:value="formData.customerOpinion" :rows="3" placeholder="请输入客户意见反馈" maxlength="2000" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
@@ -116,26 +209,119 @@
|
||||
|
||||
<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 { message, Modal } from 'ant-design-vue';
|
||||
import { SearchOutlined, ReloadOutlined, PlusOutlined, DownOutlined } from '@ant-design/icons-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { visitRecordApi, type VisitRecordInfo, type VisitRecordQuery } from '@/api/visitRecord';
|
||||
import { customerApi } from '@/api/customer';
|
||||
import { customerApi, type CustomerInfo } from '@/api/customer';
|
||||
import { useTableScroll } from '@/composables/useTableScroll';
|
||||
|
||||
const { wrapperRef, tableHeight } = useTableScroll();
|
||||
|
||||
// ==================== 列表数据 ====================
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<VisitRecordInfo[]>([]);
|
||||
const total = ref(0);
|
||||
const searchForm = reactive<VisitRecordQuery>({ page: 1, pageSize: 10, keyword: '' });
|
||||
|
||||
const searchForm = reactive<VisitRecordQuery>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
customerId: undefined as number | undefined,
|
||||
});
|
||||
const visitRange = ref<any>(undefined);
|
||||
|
||||
const columns = [
|
||||
{ title: '客户', key: 'customerName', width: 220 },
|
||||
{ title: '回访人', key: 'visitor', width: 120 },
|
||||
{ title: '回访时间', key: 'visitTime', width: 160 },
|
||||
{ title: '回访内容摘要', key: 'customerOpinion', ellipsis: true },
|
||||
{ title: '操作', key: 'action', align: 'center' as const, width: 170, 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 }[]>([]);
|
||||
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomerName(customerId?: number): string {
|
||||
if (!customerId) return '—';
|
||||
const found = customerOptions.value.find((c) => c.value === customerId);
|
||||
return found ? found.label : `#${customerId}`;
|
||||
}
|
||||
|
||||
// ==================== 数据请求 ====================
|
||||
function buildQuery(): VisitRecordQuery {
|
||||
const range = visitRange.value as any[] | undefined;
|
||||
return {
|
||||
page: searchForm.page,
|
||||
pageSize: searchForm.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
customerId: searchForm.customerId,
|
||||
visitStartDate: range?.[0] ? range[0].format('YYYY-MM-DD') : undefined,
|
||||
visitEndDate: range?.[1] ? range[1].format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await visitRecordApi.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;
|
||||
visitRange.value = undefined;
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function handlePageChange(page: number, pageSize: number) {
|
||||
searchForm.page = page;
|
||||
searchForm.pageSize = pageSize;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
// ==================== 弹窗表单 ====================
|
||||
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 customerOptions = ref<{ label: string; value: number }[]>([]);
|
||||
|
||||
const formData = reactive({
|
||||
customerId: undefined as number | undefined,
|
||||
@@ -151,78 +337,18 @@ const formRules = {
|
||||
contactPhone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '所属客户', key: 'customerName', ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contactPerson', key: 'contactPerson', ellipsis: true },
|
||||
{ title: '联系电话', dataIndex: 'contactPhone', key: 'contactPhone', ellipsis: true },
|
||||
{ title: '回访人', dataIndex: 'visitor', key: 'visitor', ellipsis: true },
|
||||
{ title: '回访时间', key: 'visitTime', width: 110 },
|
||||
{ title: '客户意见', dataIndex: 'customerOpinion', key: 'customerOpinion', ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: searchForm.page,
|
||||
pageSize: searchForm.pageSize,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t: number) => `共 ${t} 条数据`,
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
fetchCustomerOptions();
|
||||
});
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await visitRecordApi.getList(searchForm);
|
||||
if (res.code === 200) {
|
||||
dataSource.value = res.data.list;
|
||||
total.value = res.data.total;
|
||||
}
|
||||
} catch {
|
||||
message.error('获取回访记录失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
function resetForm() {
|
||||
formData.customerId = undefined;
|
||||
formData.contactPerson = '';
|
||||
formData.contactPhone = '';
|
||||
formData.visitor = '';
|
||||
formData.visitTime = undefined;
|
||||
formData.customerOpinion = '';
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCustomerOptions() {
|
||||
try {
|
||||
const res: any = await customerApi.getCustomerOptions();
|
||||
if (res.code === 200) {
|
||||
customerOptions.value = res.data.map((item: any) => ({ label: item.customerName, value: item.id }));
|
||||
}
|
||||
} catch {
|
||||
console.error('获取客户选项失败');
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomerName(customerId: number): string {
|
||||
const found = customerOptions.value.find((c) => c.value === customerId);
|
||||
return found ? found.label : `#${customerId}`;
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = '';
|
||||
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;
|
||||
@@ -237,26 +363,15 @@ function handleEdit(record: VisitRecordInfo) {
|
||||
modalTitle.value = '编辑回访';
|
||||
resetForm();
|
||||
formData.customerId = record.customerId;
|
||||
formData.contactPerson = record.contactPerson;
|
||||
formData.contactPhone = record.contactPhone;
|
||||
formData.visitor = record.visitor;
|
||||
formData.contactPerson = record.contactPerson || '';
|
||||
formData.contactPhone = record.contactPhone || '';
|
||||
formData.visitor = record.visitor || '';
|
||||
formData.visitTime = record.visitTime ? dayjs(record.visitTime) : undefined;
|
||||
formData.customerOpinion = record.customerOpinion;
|
||||
formData.customerOpinion = record.customerOpinion || '';
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.customerId = undefined;
|
||||
formData.contactPerson = '';
|
||||
formData.contactPhone = '';
|
||||
formData.visitor = '';
|
||||
formData.visitTime = undefined;
|
||||
formData.customerOpinion = '';
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 提交 ====================
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
@@ -266,31 +381,26 @@ async function handleSubmit() {
|
||||
|
||||
modalLoading.value = true;
|
||||
try {
|
||||
const submitData: any = {
|
||||
const submitData = {
|
||||
customerId: formData.customerId,
|
||||
contactPerson: formData.contactPerson,
|
||||
contactPhone: formData.contactPhone,
|
||||
visitor: formData.visitor,
|
||||
contactPerson: formData.contactPerson || undefined,
|
||||
contactPhone: formData.contactPhone || undefined,
|
||||
visitor: formData.visitor || undefined,
|
||||
visitTime: formData.visitTime ? formData.visitTime.format('YYYY-MM-DD') : undefined,
|
||||
customerOpinion: formData.customerOpinion,
|
||||
customerOpinion: formData.customerOpinion || undefined,
|
||||
};
|
||||
|
||||
let res: any;
|
||||
if (isEdit.value && editId.value) {
|
||||
res = await visitRecordApi.update(editId.value, submitData);
|
||||
await visitRecordApi.update(editId.value, submitData);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
res = await visitRecordApi.create(submitData);
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功');
|
||||
modalVisible.value = false;
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message || '操作失败');
|
||||
await visitRecordApi.create(submitData);
|
||||
message.success('创建成功');
|
||||
}
|
||||
modalVisible.value = false;
|
||||
fetchList();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
modalLoading.value = false;
|
||||
}
|
||||
@@ -301,61 +411,314 @@ function handleCancel() {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
const res: any = await visitRecordApi.delete(id);
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message || '删除失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
// ==================== 行内操作 ====================
|
||||
function handleMore(key: string, record: VisitRecordInfo) {
|
||||
if (key === 'delete') return confirmDelete(record);
|
||||
}
|
||||
|
||||
function confirmDelete(record: VisitRecordInfo) {
|
||||
Modal.confirm({
|
||||
title: '确认删除该回访记录?',
|
||||
content: `客户「${getCustomerName(record.customerId)}」的回访记录将被删除,此操作不可恢复。`,
|
||||
okText: '确认删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
await visitRecordApi.delete(record.id);
|
||||
message.success('删除成功');
|
||||
fetchList();
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-management {
|
||||
.visit-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
padding: 20px 24px;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
|
||||
}
|
||||
:deep(.ant-card) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
:deep(.ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.search-bar {
|
||||
|
||||
.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;
|
||||
justify-content: flex-end;
|
||||
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 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: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
}
|
||||
|
||||
.cust-name {
|
||||
font-weight: 600;
|
||||
color: #0d1c2c;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plan-time {
|
||||
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.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
font-size: 14px;
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
: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>
|
||||
Reference in new issue
Block a user