feat(店铺评分监控): 添加预警设置功能并优化界面布局
- 新增预警设置对话框,支持口碑分和体验分阈值配置 - 将预警设置从页面内移至弹窗中,优化页面空间利用率 - 调整卡片头部样式,增加设置按钮 - 优化表单和表格的响应式布局
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user