feat(店铺评分监控): 添加预警设置功能并优化界面布局

- 新增预警设置对话框,支持口碑分和体验分阈值配置
- 将预警设置从页面内移至弹窗中,优化页面空间利用率
- 调整卡片头部样式,增加设置按钮
- 优化表单和表格的响应式布局
This commit is contained in:
2026-04-09 09:52:00 +08:00
parent fe1ebce332
commit 091a159eec

View File

@@ -1,10 +1,20 @@
<template>
<div class="trade-operation-analysis-shop">
<el-card shadow="hover">
<template #header><div class="card-header"><span>店铺评分监控</span></div></template>
<template #header>
<div class="card-header">
<span>店铺评分监控</span>
<el-button type="text" @click="openSettingDialog" class="setting-btn">
<el-icon><eleSetting /></el-icon>
<span>预警设置</span>
</el-button>
</div>
</template>
<div class="chart-container">
<el-card>
<template #header><div class="card-header">评分趋势 - {{ selectedShopName }}</div></template>
<template #header
><div class="card-header">评分趋势 - {{ selectedShopName }}</div></template
>
<div ref="scoreChartRef" class="chart"></div>
</el-card>
</div>
@@ -14,14 +24,18 @@
<div class="search-container">
<el-form :model="searchParams" :inline="true" class="search-form">
<el-form-item label="店铺搜索">
<el-input
v-model="searchParams.shopKeyword"
placeholder="请输入店铺名称或店铺ID"
clearable
@keyup.enter="handleSearch"
/>
<el-input v-model="searchParams.shopKeyword" placeholder="请输入店铺名称或店铺ID" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="时间范围"><el-date-picker v-model="searchParams.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" :shortcuts="dateShortcuts" /></el-form-item>
<el-form-item label="时间范围"
><el-date-picker
v-model="searchParams.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
:shortcuts="dateShortcuts"
/></el-form-item>
<el-form-item label="时间粒度">
<el-radio-group v-model="searchParams.granularity" class="granularity-group">
<el-radio-button v-for="option in granularityOptions" :key="option.value" :label="option.value">
@@ -29,41 +43,119 @@
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item><el-button type="primary" @click="handleSearch">查询</el-button><el-button @click="handleReset">重置</el-button></el-form-item>
<el-form-item
><el-button type="primary" @click="handleSearch">查询</el-button><el-button @click="handleReset">重置</el-button></el-form-item
>
</el-form>
</div>
<el-table :data="pagedShopList" style="width: 100%" highlight-current-row @row-click="handleShopClick">
<el-table-column prop="id" label="店铺ID" width="100" />
<el-table-column prop="name" label="店铺名称" />
<el-table-column prop="type" label="店铺类型"><template #default="scope"><el-tag size="small">{{ scope.row.type === 'physical' ? '线下店铺' : '线上店铺' }}</el-tag></template></el-table-column>
<el-table-column prop="status" label="状态"><template #default="scope"><el-tag size="small" :type="scope.row.status === 'active' ? 'success' : 'danger'">{{ scope.row.status === 'active' ? '营业中' : '已关闭' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="120"><template #default="scope"><el-button type="primary" size="small" @click.stop="handleShopSelect(scope.row.id)">查看监控</el-button></template></el-table-column>
<el-table-column prop="type" label="店铺类型"
><template #default="scope"
><el-tag size="small">{{ scope.row.type === 'physical' ? '线下店铺' : '线上店铺' }}</el-tag></template
></el-table-column
>
<el-table-column prop="status" label="状态"
><template #default="scope"
><el-tag size="small" :type="scope.row.status === 'active' ? 'success' : 'danger'">{{
scope.row.status === 'active' ? '营业中' : '已关闭'
}}</el-tag></template
></el-table-column
>
<el-table-column label="操作" width="120"
><template #default="scope"
><el-button type="primary" size="small" @click.stop="handleShopSelect(scope.row.id)">查看监控</el-button></template
></el-table-column
>
</el-table>
<div class="pagination-container"><el-pagination v-model:current-page="shopPagination.currentPage" v-model:page-size="shopPagination.pageSize" :page-sizes="[5, 10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" :total="shopPagination.total" @size-change="handleShopPageSizeChange" @current-change="handleShopPageChange" /></div>
<div class="pagination-container">
<el-pagination
v-model:current-page="shopPagination.currentPage"
v-model:page-size="shopPagination.pageSize"
:page-sizes="[5, 10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="shopPagination.total"
@size-change="handleShopPageSizeChange"
@current-change="handleShopPageChange"
/>
</div>
</el-card>
</div>
<div class="score-setting"><el-card><template #header><div class="card-header">评分预警设置 - {{ selectedShopName }}</div></template><el-form :model="scoreSettings" label-width="120px"><el-form-item label="口碑分预警阈值"><el-input-number v-model="scoreSettings.reputationThreshold" :min="0" :max="5" :step="0.1" /></el-form-item><el-form-item label="体验分预警阈值"><el-input-number v-model="scoreSettings.experienceThreshold" :min="0" :max="5" :step="0.1" /></el-form-item><el-form-item><el-button type="primary" @click="handleSaveSettings">保存设置</el-button></el-form-item></el-form></el-card></div>
<div class="score-detail">
<el-card>
<template #header><div class="card-header">评分详情 - {{ selectedShopName }}</div></template>
<template #header
><div class="card-header">评分详情 - {{ selectedShopName }}</div></template
>
<el-table :data="pagedScoreDetail" style="width: 100%">
<el-table-column prop="date" label="日期" />
<el-table-column prop="reputationScore" label="口碑分"><template #default="scope"><div class="score-item" :class="{ warning: scope.row.reputationScore < scoreSettings.reputationThreshold }">{{ scope.row.reputationScore }}</div></template></el-table-column>
<el-table-column prop="experienceScore" label="体验分"><template #default="scope"><div class="score-item" :class="{ warning: scope.row.experienceScore < scoreSettings.experienceThreshold }">{{ scope.row.experienceScore }}</div></template></el-table-column>
<el-table-column prop="reputationScore" label="口碑分"
><template #default="scope"
><div class="score-item" :class="{ warning: scope.row.reputationScore < scoreSettings.reputationThreshold }">
{{ scope.row.reputationScore }}
</div></template
></el-table-column
>
<el-table-column prop="experienceScore" label="体验分"
><template #default="scope"
><div class="score-item" :class="{ warning: scope.row.experienceScore < scoreSettings.experienceThreshold }">
{{ scope.row.experienceScore }}
</div></template
></el-table-column
>
<el-table-column prop="commentCount" label="评价数量" />
</el-table>
<div class="pagination-container"><el-pagination v-model:current-page="detailPagination.currentPage" v-model:page-size="detailPagination.pageSize" :page-sizes="[5, 10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" :total="detailPagination.total" @size-change="handleDetailPageSizeChange" @current-change="handleDetailPageChange" /></div>
<div class="pagination-container">
<el-pagination
v-model:current-page="detailPagination.currentPage"
v-model:page-size="detailPagination.pageSize"
:page-sizes="[5, 10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="detailPagination.total"
@size-change="handleDetailPageSizeChange"
@current-change="handleDetailPageChange"
/>
</div>
</el-card>
</div>
</el-card>
<!-- 评分预警设置弹窗 -->
<el-dialog v-model="dialogVisible" :title="'评分预警设置 - ' + selectedShopName" width="400px">
<el-form :model="scoreSettings" label-width="120px">
<el-form-item label="口碑分预警阈值">
<el-input-number v-model="scoreSettings.reputationThreshold" :min="0" :max="5" :step="0.1" />
</el-form-item>
<el-form-item label="体验分预警阈值">
<el-input-number v-model="scoreSettings.experienceThreshold" :min="0" :max="5" :step="0.1" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveSettings">保存设置</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import * as echarts from 'echarts';
interface Shop { id: string; name: string; type: string; status: string }
interface ScoreDetail { date: string; reputationScore: number; experienceScore: number; commentCount: number }
import { Setting as eleSetting } from '@element-plus/icons-vue';
interface Shop {
id: string;
name: string;
type: string;
status: string;
}
interface ScoreDetail {
date: string;
reputationScore: number;
experienceScore: number;
commentCount: number;
}
const granularityOptions = [
{ label: '小时', value: 'hour' },
{ label: '日', value: 'day' },
@@ -73,8 +165,53 @@ const granularityOptions = [
{ label: '年', value: 'year' },
];
const searchParams = reactive({ shopId: 'all', shopKeyword: '', dateRange: [], granularity: 'day' });
const dateShortcuts = [{ text: '最近7天', value: () => { const end = new Date(); const start = new Date(); start.setTime(start.getTime() - 3600 * 1000 * 24 * 7); return [start, end]; } }, { text: '最近30天', value: () => { const end = new Date(); const start = new Date(); start.setTime(start.getTime() - 3600 * 1000 * 24 * 30); return [start, end]; } }, { text: '最近90天', value: () => { const end = new Date(); const start = new Date(); start.setTime(start.getTime() - 3600 * 1000 * 24 * 90); return [start, end]; } }, { text: '今年', value: () => { const end = new Date(); const start = new Date(new Date().getFullYear(), 0, 1); return [start, end]; } }, { text: '去年', value: () => { const end = new Date(new Date().getFullYear() - 1, 11, 31); const start = new Date(new Date().getFullYear() - 1, 0, 1); return [start, end]; } }];
const dateShortcuts = [
{
text: '最近7天',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
return [start, end];
},
},
{
text: '最近30天',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
return [start, end];
},
},
{
text: '最近90天',
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
return [start, end];
},
},
{
text: '今年',
value: () => {
const end = new Date();
const start = new Date(new Date().getFullYear(), 0, 1);
return [start, end];
},
},
{
text: '去年',
value: () => {
const end = new Date(new Date().getFullYear() - 1, 11, 31);
const start = new Date(new Date().getFullYear() - 1, 0, 1);
return [start, end];
},
},
];
const scoreSettings = reactive({ reputationThreshold: 4.0, experienceThreshold: 4.2 });
const dialogVisible = ref(false);
const shopList = ref<Shop[]>([]);
const scoreDetail = ref<ScoreDetail[]>([]);
const filteredShopList = computed(() => {
@@ -87,11 +224,23 @@ const detailPagination = reactive({ currentPage: 1, pageSize: 5, total: 0 });
const pagedShopList = computed(() =>
filteredShopList.value.slice((shopPagination.currentPage - 1) * shopPagination.pageSize, shopPagination.currentPage * shopPagination.pageSize)
);
const pagedScoreDetail = computed(() => scoreDetail.value.slice((detailPagination.currentPage - 1) * detailPagination.pageSize, detailPagination.currentPage * detailPagination.pageSize));
const pagedScoreDetail = computed(() =>
scoreDetail.value.slice((detailPagination.currentPage - 1) * detailPagination.pageSize, detailPagination.currentPage * detailPagination.pageSize)
);
const scoreChartRef = ref();
let scoreChart: echarts.ECharts | null = null;
const getMockShopList = () => [{ id: '1', name: '旗舰店', type: 'online', status: 'active' }, { id: '2', name: '华东直营网点', type: 'physical', status: 'active' }, { id: '3', name: '华南直营店', type: 'physical', status: 'active' }, { id: '4', name: '品牌商城', type: 'online', status: 'active' }, { id: '5', name: '北区体验店', type: 'physical', status: 'closed' }];
const selectedShopName = computed(() => { if (searchParams.shopId === 'all') return '全部店铺'; const shop = shopList.value.find((item) => String(item.id) === searchParams.shopId); return shop?.name || '未知店铺'; });
const getMockShopList = () => [
{ id: '1', name: '旗舰店', type: 'online', status: 'active' },
{ id: '2', name: '华东直营网点', type: 'physical', status: 'active' },
{ id: '3', name: '华南直营店', type: 'physical', status: 'active' },
{ id: '4', name: '品牌商城', type: 'online', status: 'active' },
{ id: '5', name: '北区体验店', type: 'physical', status: 'closed' },
];
const selectedShopName = computed(() => {
if (searchParams.shopId === 'all') return '全部店铺';
const shop = shopList.value.find((item) => String(item.id) === searchParams.shopId);
return shop?.name || '未知店铺';
});
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const toDate = (value?: string | Date) => {
if (!value) return new Date();
@@ -216,12 +365,57 @@ const handleShopSelect = (shopId: string) => {
searchParams.shopId = String(shopId);
handleSearch();
};
const handleShopPageSizeChange = (size: number) => { shopPagination.pageSize = size; shopPagination.currentPage = 1; };
const handleShopPageChange = (page: number) => { shopPagination.currentPage = page; };
const handleDetailPageSizeChange = (size: number) => { detailPagination.pageSize = size; detailPagination.currentPage = 1; };
const handleDetailPageChange = (page: number) => { detailPagination.currentPage = page; };
const handleSaveSettings = () => {};
const initScoreChart = (scoreTrend: Array<{ date: string; reputationScore: number; experienceScore: number }>) => { if (!scoreChartRef.value) return; if (scoreChart) scoreChart.dispose(); scoreChart = echarts.init(scoreChartRef.value); const isSingle = scoreTrend.length <= 1; scoreChart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } }, legend: { data: ['口碑分', '体验分'] }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', boundaryGap: isSingle, data: scoreTrend.map((item) => item.date) }, yAxis: { type: 'value', name: '评分', min: 0, max: 5 }, series: [{ name: '口碑分', type: 'line', data: scoreTrend.map((item) => item.reputationScore), smooth: true, showSymbol: true, symbolSize: isSingle ? 10 : 6 }, { name: '体验分', type: 'line', data: scoreTrend.map((item) => item.experienceScore), smooth: true, showSymbol: true, symbolSize: isSingle ? 10 : 6 }] }); };
const handleShopPageSizeChange = (size: number) => {
shopPagination.pageSize = size;
shopPagination.currentPage = 1;
};
const handleShopPageChange = (page: number) => {
shopPagination.currentPage = page;
};
const handleDetailPageSizeChange = (size: number) => {
detailPagination.pageSize = size;
detailPagination.currentPage = 1;
};
const handleDetailPageChange = (page: number) => {
detailPagination.currentPage = page;
};
const openSettingDialog = () => {
dialogVisible.value = true;
};
const handleSaveSettings = () => {
dialogVisible.value = false;
};
const initScoreChart = (scoreTrend: Array<{ date: string; reputationScore: number; experienceScore: number }>) => {
if (!scoreChartRef.value) return;
if (scoreChart) scoreChart.dispose();
scoreChart = echarts.init(scoreChartRef.value);
const isSingle = scoreTrend.length <= 1;
scoreChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } } },
legend: { data: ['口碑分', '体验分'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', boundaryGap: isSingle, data: scoreTrend.map((item) => item.date) },
yAxis: { type: 'value', name: '评分', min: 0, max: 5 },
series: [
{
name: '口碑分',
type: 'line',
data: scoreTrend.map((item) => item.reputationScore),
smooth: true,
showSymbol: true,
symbolSize: isSingle ? 10 : 6,
},
{
name: '体验分',
type: 'line',
data: scoreTrend.map((item) => item.experienceScore),
smooth: true,
showSymbol: true,
symbolSize: isSingle ? 10 : 6,
},
],
});
};
watch(
() => filteredShopList.value.length,
(length) => {
@@ -232,23 +426,74 @@ watch(
}
}
);
watch(() => searchParams.granularity, () => handleSearch());
onMounted(() => { shopList.value = getMockShopList(); shopPagination.total = shopList.value.length; handleSearch(); window.addEventListener('resize', () => scoreChart?.resize()); });
watch(
() => searchParams.granularity,
() => handleSearch()
);
onMounted(() => {
shopList.value = getMockShopList();
shopPagination.total = shopList.value.length;
handleSearch();
window.addEventListener('resize', () => scoreChart?.resize());
});
</script>
<style scoped>
.trade-operation-analysis-shop { padding: 20px; }
.card-header { display: flex; align-items: center; justify-content: space-between; font-size: 16px; font-weight: 600; }
.search-container { margin-bottom: 16px; }
.search-form { display: flex; align-items: center; flex-wrap: wrap; }
.search-form :deep(.el-form-item) { margin-right: 12px; margin-bottom: 12px; }
.granularity-group { flex-wrap: wrap; }
.shop-list { margin-bottom: 20px; }
.score-setting { margin-bottom: 20px; }
.chart-container { margin: 0 0 20px; }
.chart { width: 100%; height: 400px; }
.score-detail { margin-top: 20px; }
.score-item { padding: 2px 8px; border-radius: 10px; display: inline-block; }
.score-item.warning { background-color: #fde2e2; color: #f56c6c; }
.pagination-container { margin-top: 16px; display: flex; justify-content: flex-end; }
.trade-operation-analysis-shop {
padding: 20px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 16px;
font-weight: 600;
}
.setting-btn {
display: flex;
align-items: center;
gap: 4px;
}
.search-container {
margin-bottom: 16px;
}
.search-form {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.search-form :deep(.el-form-item) {
margin-right: 12px;
margin-bottom: 12px;
}
.granularity-group {
flex-wrap: wrap;
}
.shop-list {
margin-bottom: 20px;
}
.chart-container {
margin: 0 0 20px;
}
.chart {
width: 100%;
height: 400px;
}
.score-detail {
margin-top: 20px;
}
.score-item {
padding: 2px 8px;
border-radius: 10px;
display: inline-block;
}
.score-item.warning {
background-color: #fde2e2;
color: #f56c6c;
}
.pagination-container {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>