feat(跑店填报): 自主填报列表支持驻店/跑店切换与跑店详情

跑店填报数据在新表 yxe_visit_report,字段与 yxe_self_report 不同,
列表用顶部单选切换类型、各自分页与导出,不做 UNION 混排。

- 新增 src/api/yxe/yxeVisitReport(index.ts + model)
- 自主填报列表页顶部加「驻店填报 / 跑店填报」单选:
  切换时同时换数据源、换列定义、清空选中,并重置到第一页
- 跑店列:ID / 提交人 / 巡店门店数 / 活动小结 / 奖金 / 状态 / 驳回原因 /
  创建时间 / 操作;驻店列保持不变
- 新增 yxeVisitReportDetail:展示巡店列表(每条含连锁、门店、定位、
  打卡照片),展开详情时按 id 拉完整 store_list
- 状态切换与批量删除、导出按当前类型路由到对应接口
- 搜索栏在跑店类型下隐藏「活动时间」:yxe_visit_report 无 activity_time 列

验证:vue-tsc 本次改动文件 0 错误;vite build 成功。
This commit is contained in:
2026-10-11 22:50:25 +08:00
parent 039b5b06be
commit f723279a95
5 changed files with 548 additions and 21 deletions
+97
View File
@@ -0,0 +1,97 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { YxeVisitReport, YxeVisitReportParam } from './model';
import { MODULES_API_URL } from '@/config/setting';
import type { FileRecord } from '@/api/system/file/model';
export async function pageYxeVisitReport(params: YxeVisitReportParam) {
const res = await request.get<ApiResult<PageResult<YxeVisitReport>>>(
MODULES_API_URL + '/yxe/yxe-visit-report/page',
{
params
}
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function listYxeVisitReport(params?: YxeVisitReportParam) {
const res = await request.get<ApiResult<YxeVisitReport[]>>(
MODULES_API_URL + '/yxe/yxe-visit-report',
{
params
}
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function addYxeVisitReport(data: YxeVisitReport) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/yxe/yxe-visit-report',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function updateYxeVisitReport(data: YxeVisitReport) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/yxe/yxe-visit-report',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removeYxeVisitReport(id?: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/yxe/yxe-visit-report/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function removeBatchYxeVisitReport(data: (number | undefined)[]) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/yxe/yxe-visit-report/batch',
{
data
}
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
export async function getYxeVisitReport(id: number) {
const res = await request.get<ApiResult<YxeVisitReport>>(
MODULES_API_URL + '/yxe/yxe-visit-report/' + id
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
export async function exportYxeVisitReport(params?: YxeVisitReportParam) {
const res = await request.post<ApiResult<FileRecord>>(
MODULES_API_URL + '/yxe/yxe-visit-report/export',
params
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+57
View File
@@ -0,0 +1,57 @@
import type { PageParam } from '@/api';
import type { User } from '@/api/system/user/model';
/** 巡店单条明细(store_list JSON 反序列化后的结构) */
export interface YxeVisitReportStore {
merchantId?: number | string;
merchantName?: string;
chainId?: string;
chainName?: string;
visitTime?: string;
province?: string;
city?: string;
merchantLat?: string | number;
merchantLng?: string | number;
merchantAddress?: string;
lat?: string | number;
lng?: string | number;
location?: string;
photos?: string[];
}
export interface YxeVisitReport {
id?: number;
userId?: number;
storeCount?: number;
storeList?: string;
image?: string;
summary?: string;
reward?: number | string;
lat?: string;
lng?: string;
location?: string;
sortNumber?: number;
comments?: string;
status?: number;
rejectReason?: string;
deleted?: number;
tenantId?: number;
createTime?: string;
updateTime?: string;
user?: User;
realName?: string;
userGroup?: string;
nickname?: string;
phone?: string;
}
export interface YxeVisitReportParam extends PageParam {
id?: number;
idList?: string;
keywords?: string;
realName?: string;
userGroup?: string;
chainName?: string;
merchantName?: string;
status?: number | number[];
}
@@ -29,6 +29,7 @@
@pressEnter="search"
/>
<a-input
v-if="reportType !== 'visit'"
v-model:value.trim="where.activityTime"
allow-clear
placeholder="请输入活动时间"
@@ -72,6 +73,8 @@
const props = defineProps<{
selection?: [];
// 'visit' 时共享字段会误导后端:跑店填报没有 activity_time / chain_id
reportType?: 'self' | 'visit';
}>();
const emit = defineEmits<{
@@ -0,0 +1,224 @@
<template>
<ele-modal
title="跑店填报详情"
:width="980"
:footer="null"
:visible="visible"
@update:visible="updateVisible"
>
<a-spin :spinning="detailLoading">
<a-space direction="vertical" class="ele-fluid">
<a-descriptions bordered :column="{ xs: 1, sm: 2 }">
<a-descriptions-item label="ID">
{{ formatText(detailData?.id) }}
</a-descriptions-item>
<a-descriptions-item label="提交人">
{{ submitterText }}
</a-descriptions-item>
<a-descriptions-item label="巡店门店数">
{{ storeRows.length }} 家
</a-descriptions-item>
<a-descriptions-item label="奖励金额">
{{ formatText(detailData?.reward) }}元
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="detailData?.status === 0">待审核</a-tag>
<a-tag v-else-if="detailData?.status === 1" color="green">通过</a-tag>
<a-tag v-else-if="detailData?.status === 2" color="red">拒绝</a-tag>
<span v-else class="ele-text-secondary">-</span>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatDateTime(detailData?.createTime) }}
</a-descriptions-item>
<a-descriptions-item v-if="detailData?.status === 2 || detailData?.rejectReason" label="驳回原因" :span="2">
<div class="detail-text">{{ formatText(detailData?.rejectReason) }}</div>
</a-descriptions-item>
<a-descriptions-item label="活动小结" :span="2">
<div class="detail-text">{{ formatSummary(detailData?.summary) }}</div>
</a-descriptions-item>
</a-descriptions>
<a-empty v-if="!storeRows.length" description="暂无巡店记录" />
<a-card v-for="(store, index) in storeRows" :key="`store-${index}`" size="small">
<template #title>
<a-space>
<span>巡店{{ index + 1 }}</span>
<span class="ele-text-secondary">{{ store.visitTime || '-' }}</span>
</a-space>
</template>
<a-descriptions bordered size="small" :column="{ xs: 1, sm: 2 }">
<a-descriptions-item label="连锁">
{{ formatText(store.chainName) }}
</a-descriptions-item>
<a-descriptions-item label="门店">
{{ formatText(store.merchantName) }}
</a-descriptions-item>
<a-descriptions-item label="定位" :span="2">
{{ store.location || '-' }}
<span v-if="store.lat && store.lng" class="ele-text-secondary">
({{ store.lat }}, {{ store.lng }})
</span>
</a-descriptions-item>
</a-descriptions>
<a-space v-if="store.photos.length" wrap class="store-photos">
<a-image
v-for="(photo, photoIndex) in store.photos"
:key="`store-${index}-photo-${photoIndex}`"
:src="photo"
:width="110"
/>
</a-space>
<div v-else class="ele-text-secondary">暂无巡店照片</div>
</a-card>
</a-space>
</a-spin>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { toDateString } from 'ele-admin-pro';
import { getYxeVisitReport } from '@/api/yxe/yxeVisitReport';
import type {
YxeVisitReport,
YxeVisitReportStore
} from '@/api/yxe/yxeVisitReport/model';
const props = defineProps<{
visible: boolean;
data?: YxeVisitReport | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
}>();
const detailLoading = ref(false);
const detailData = ref<YxeVisitReport | null>(null);
let detailRequestSeed = 0;
const formatText = (value: unknown) => {
if (value === null || value === undefined) {
return '-';
}
const text = String(value).trim();
return text ? text : '-';
};
const formatDateTime = (value?: string) => {
return value ? toDateString(value, 'yyyy-MM-dd HH:mm:ss') : '-';
};
const parseJson = <T = any>(value?: string | null): T[] => {
const raw = String(value ?? '').trim();
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
};
const parseImageList = (value?: unknown) => {
const list = Array.isArray(value) ? value : [];
return list
.map((item) =>
typeof item === 'string'
? item
: String((item as any)?.url ?? (item as any)?.path ?? '').trim()
)
.filter(Boolean);
};
const storeRows = computed<YxeVisitReportStore[]>(() =>
parseJson<YxeVisitReportStore>(detailData.value?.storeList).map((store) => ({
...store,
photos: parseImageList(store?.photos)
}))
);
const submitterText = computed(() => {
const user = detailData.value?.user;
const name =
user?.realName ||
user?.nickname ||
detailData.value?.realName ||
detailData.value?.nickname;
const phone = user?.phone || detailData.value?.phone;
if (name && phone) return `${name} (${phone})`;
return formatText(name || phone || detailData.value?.userId);
});
const formatSummary = (value?: string | null) => {
const raw = String(value ?? '').trim();
if (!raw) return '-';
try {
return JSON.parse(raw) || '-';
} catch (error) {
return raw;
}
};
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
const resetDetailState = () => {
detailRequestSeed += 1;
detailLoading.value = false;
detailData.value = null;
};
const loadDetail = async (row?: YxeVisitReport | null) => {
const currentRow = row ?? null;
detailData.value = currentRow;
if (!currentRow?.id) {
detailLoading.value = false;
return;
}
const requestSeed = ++detailRequestSeed;
detailLoading.value = true;
try {
const data = await getYxeVisitReport(currentRow.id);
if (requestSeed !== detailRequestSeed) {
return;
}
detailData.value = data;
} catch (e: any) {
if (requestSeed !== detailRequestSeed) {
return;
}
message.error(e.message);
detailData.value = currentRow;
} finally {
if (requestSeed === detailRequestSeed) {
detailLoading.value = false;
}
}
};
watch(
() => [props.visible, props.data?.id] as const,
([visible]) => {
if (!visible) {
resetDetailState();
return;
}
loadDetail(props.data);
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.detail-text {
white-space: pre-line;
word-break: break-word;
}
.store-photos {
margin-top: 12px;
}
</style>
+167 -21
View File
@@ -14,9 +14,20 @@
class="sys-org-table"
>
<template #toolbar>
<a-space :size="10" style="flex-wrap: wrap; margin-right: 10px">
<a-radio-group
v-model:value="reportType"
button-style="solid"
@change="changeReportType"
>
<a-radio-button value="self">驻店填报</a-radio-button>
<a-radio-button value="visit">跑店填报</a-radio-button>
</a-radio-group>
</a-space>
<search
@search="reload"
:selection="selection"
:report-type="reportType"
@remove="removeBatch"
@export="exportData"
/>
@@ -79,10 +90,17 @@
</a-card>
<YxeSelfReportDetail
v-if="reportType === 'self'"
v-model:visible="showDetail"
:data="detailCurrent"
@update:visible="updateDetailVisible"
/>
<YxeVisitReportDetail
v-else
v-model:visible="showDetail"
:data="visitDetailCurrent"
@update:visible="updateDetailVisible"
/>
<a-modal v-model:visible="imagePreviewVisible" title="图片查看" :footer="null" width="800px">
<a-image-preview-group>
<a-space wrap>
@@ -135,6 +153,7 @@
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import YxeSelfReportDetail from './components/yxeSelfReportDetail.vue';
import YxeVisitReportDetail from './components/yxeVisitReportDetail.vue';
import {
exportYxeSelfReport,
pageYxeSelfReport,
@@ -142,16 +161,31 @@
removeYxeSelfReport,
updateYxeSelfReport
} from '@/api/yxe/yxeSelfReport';
import {
exportYxeVisitReport,
pageYxeVisitReport,
removeBatchYxeVisitReport,
removeYxeVisitReport,
updateYxeVisitReport
} from '@/api/yxe/yxeVisitReport';
import type {
YxeSelfReport,
YxeSelfReportParam
} from '@/api/yxe/yxeSelfReport/model';
import type { YxeVisitReport } from '@/api/yxe/yxeVisitReport/model';
import { openExportFile } from '@/utils/export-file';
// 驻店填报(yxe_self_report)与跑店填报(yxe_visit_report)是两张表,
// 列表用顶部单选切换,各自分页/导出,不做 UNION 混排。
const reportType = ref<'self' | 'visit'>('self');
// 两个接口返回的字段不同,统一按「至少要有什么」约束,避免铺一层两表并集的大类型
type ReportRow = YxeSelfReport | YxeVisitReport;
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const selection = ref<YxeSelfReport[]>([]);
const selection = ref<ReportRow[]>([]);
const showDetail = ref(false);
const detailCurrent = ref<YxeSelfReport | null>(null);
const visitDetailCurrent = ref<YxeVisitReport | null>(null);
const imagePreviewVisible = ref(false);
const previewImages = ref<string[]>([]);
@@ -161,19 +195,19 @@
};
const rewardModalVisible = ref(false);
const rewardModalLoading = ref(false);
const pendingRewardRow = ref<YxeSelfReport | null>(null);
const pendingRewardRow = ref<ReportRow | null>(null);
const rewardForm = reactive<{ reward: number | null }>({
reward: 0
});
const getSubmitterName = (record: YxeSelfReport) =>
const getSubmitterName = (record: ReportRow) =>
record.user?.realName ||
record.realName ||
record.user?.nickname ||
record.nickname ||
'-';
const getSubmitterPhone = (record: YxeSelfReport) =>
const getSubmitterPhone = (record: ReportRow) =>
record.user?.phone || record.phone || '-';
const parseJsonArray = (value?: string | null) => {
@@ -199,6 +233,20 @@
)
.filter(Boolean);
// 小结按 JSON 字符串存,列表里要解回纯文本,否则会带引号
const formatSummaryText = (value?: string | null) => {
const raw = String(value ?? '').trim();
if (!raw) return '-';
try {
const parsed = JSON.parse(raw);
if (parsed === null || parsed === undefined) return '-';
const text = String(parsed).trim();
return text || '-';
} catch (error) {
return raw;
}
};
const formatRewardText = (value?: number | string | null) => {
if (value === null || value === undefined || value === '') {
return '-';
@@ -220,7 +268,8 @@
if (filters && filters.status !== void 0) {
where.status = filters.status;
}
return pageYxeSelfReport({
const request = reportType.value === 'visit' ? pageYxeVisitReport : pageYxeSelfReport;
return request({
...where,
...orders,
page,
@@ -228,7 +277,8 @@
});
};
const columns = ref<ColumnItem[]>([
// 两表字段不同,列按当前类型切换
const selfColumns: ColumnItem[] = [
{
title: 'ID',
dataIndex: 'id',
@@ -334,7 +384,87 @@
align: 'center',
hideInSetting: true
}
]);
];
const visitColumns: ColumnItem[] = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'center',
width: 90
},
{
title: '提交人',
key: 'submitter',
align: 'center',
minWidth: 170
},
{
title: '巡店门店数',
dataIndex: 'storeCount',
key: 'storeCount',
align: 'center',
width: 110
},
{
title: '活动小结',
dataIndex: 'summary',
key: 'summary',
align: 'center',
minWidth: 200,
customRender: ({ text }) => formatSummaryText(text)
},
{
title: '奖金金额',
dataIndex: 'reward',
key: 'reward',
align: 'center',
width: 120,
customRender: ({ text }) => formatRewardText(text)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 130
},
{
title: '驳回原因',
dataIndex: 'rejectReason',
key: 'rejectReason',
align: 'center',
minWidth: 180
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
minWidth: 170,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '操作',
key: 'action',
width: 140,
fixed: 'right',
align: 'center',
hideInSetting: true
}
];
const columns = ref<ColumnItem[]>(selfColumns);
const changeReportType = () => {
selection.value = [];
detailCurrent.value = null;
visitDetailCurrent.value = null;
columns.value = reportType.value === 'visit' ? visitColumns : selfColumns;
tableRef?.value?.reload({ page: 1 });
};
const reload = (where?: YxeSelfReportParam) => {
selection.value = [];
@@ -345,16 +475,23 @@
showDetail.value = value;
if (!value) {
detailCurrent.value = null;
visitDetailCurrent.value = null;
}
};
const openDetail = (row: YxeSelfReport) => {
detailCurrent.value = row;
const openDetail = (row: ReportRow) => {
if (reportType.value === 'visit') {
visitDetailCurrent.value = row as YxeVisitReport;
} else {
detailCurrent.value = row as YxeSelfReport;
}
showDetail.value = true;
};
const remove = (row: YxeSelfReport) => {
removeYxeSelfReport(row.id)
const remove = (row: ReportRow) => {
const request =
reportType.value === 'visit' ? removeYxeVisitReport : removeYxeSelfReport;
request(row.id)
.then((msg) => {
message.success(msg);
reload();
@@ -371,13 +508,18 @@
Modal.confirm({
title: '提示',
icon: createVNode(ExclamationCircleOutlined),
content: '确定要删除选中的自主填报记录吗?',
content:
reportType.value === 'visit'
? '确定要删除选中的跑店填报记录吗?'
: '确定要删除选中的驻店填报记录吗?',
maskClosable: true,
onOk: async () => {
try {
const msg = await removeBatchYxeSelfReport(
selection.value.map((d) => d.id)
);
const request =
reportType.value === 'visit'
? removeBatchYxeVisitReport
: removeBatchYxeSelfReport;
const msg = await request(selection.value.map((d) => d.id));
message.success(msg);
reload();
} catch (e: any) {
@@ -387,9 +529,11 @@
});
};
const updateStatus = async (row: YxeSelfReport, reward?: number) => {
const updateStatus = async (row: ReportRow, reward?: number) => {
try {
const msg = await updateYxeSelfReport({
const request =
reportType.value === 'visit' ? updateYxeVisitReport : updateYxeSelfReport;
const msg = await request({
id: row.id,
status: row.status,
...(reward !== void 0 ? { reward } : {})
@@ -403,7 +547,7 @@
}
};
const openRewardModal = (row: YxeSelfReport) => {
const openRewardModal = (row: ReportRow) => {
pendingRewardRow.value = row;
const reward =
row.reward === null || row.reward === undefined || row.reward === ''
@@ -446,7 +590,7 @@
}
};
const changeStatus = (row: YxeSelfReport) => {
const changeStatus = (row: ReportRow) => {
if (row.status !== 1) {
updateStatus(row).catch(() => {});
return;
@@ -461,7 +605,9 @@
.map((item) => item.id)
.filter(Boolean)
.join(',');
exportYxeSelfReport({
const request =
reportType.value === 'visit' ? exportYxeVisitReport : exportYxeSelfReport;
request({
...(where || {}),
...(idList ? { idList } : {}),
...(orders || {}),
@@ -479,7 +625,7 @@
});
};
const customRow = (record: YxeSelfReport) => ({
const customRow = (record: ReportRow) => ({
onDblclick: () => openDetail(record)
});
</script>