You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
645 lines
16 KiB
Vue
645 lines
16 KiB
Vue
<template>
|
|
<div class="performance-score">
|
|
<!-- 顶部周期切换 -->
|
|
<div class="period-bar">
|
|
<div class="period-tabs">
|
|
<el-button v-for="t in periodTabs" :key="t.value"
|
|
:class="['period-tab', { active: period === t.value && !isCustom }]" plain @click="onPickPeriod(t.value)">
|
|
{{ t.label }}
|
|
</el-button>
|
|
<el-button :class="['period-tab', { active: isCustom }]" plain @click="onPickCustom">自定义</el-button>
|
|
</div>
|
|
<div v-if="isCustom" class="custom-range">
|
|
<el-date-picker :shortcuts="dateRangeShortcuts" v-model="customRange" type="daterange" range-separator="-" start-placeholder="开始日期"
|
|
end-placeholder="结束日期" value-format="YYYY-MM-DD" style="width: 240px" />
|
|
<el-button type="primary" class="search-btn" plain @click="onSearch">查询</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 统计卡片 -->
|
|
<div class="stat-grid">
|
|
<div class="stat-card">
|
|
<img src="@/assets/image/admin/score/total.svg" class="stat-icon">
|
|
<div class="stat-body">
|
|
<div class="stat-label">本期总积分</div>
|
|
<div class="stat-value">{{ stats.totalPoints || 0 }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<img src="@/assets/image/admin/score/manager-count.svg" class="stat-icon">
|
|
<div class="stat-body">
|
|
<div class="stat-label">客户经理人数</div>
|
|
<div class="stat-value">{{ stats.managerCount || 0 }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<img src="@/assets/image/admin/score/success-count.svg" class="stat-icon">
|
|
<div class="stat-body">
|
|
<div class="stat-label">本期完成工单</div>
|
|
<div class="stat-value">{{ stats.completedOrders || 0 }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<img src="@/assets/image/admin/score/success-ratio.svg" class="stat-icon">
|
|
<div class="stat-body">
|
|
<div class="stat-label">本期完成率</div>
|
|
<div class="stat-value">{{ stats.completionRate || 0 }}%</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 双栏:排行榜 + 规则 -->
|
|
<div class="content-grid">
|
|
<div class="leaderboard-card jifen-card">
|
|
<div class="border-title">
|
|
积分排行榜
|
|
</div>
|
|
<div v-loading="leaderboardLoading" class="leaderboard">
|
|
<el-empty v-if="!leaderboardLoading && leaderboard.length === 0" description="暂无排行数据" :image-size="80" />
|
|
<div v-for="(item, idx) in leaderboard" :key="item.managerId" class="lb-row">
|
|
<div class="lb-rank" :class="rankClass(item.rank || idx + 1)">
|
|
{{ item.rank || idx + 1 }}
|
|
</div>
|
|
<div class="lb-info">
|
|
<div class="lb-name">{{ item.managerName || "—" }}</div>
|
|
<div class="lb-meta">
|
|
负责 <span class="num">{{ item.hospitalCount || 0 }}</span> 家医院
|
|
<span class="sep">|</span>
|
|
本期完成 <span class="num">{{ item.completedOrders || 0 }}</span> 单
|
|
</div>
|
|
</div>
|
|
<div class="lb-points">{{ item.points || 0 }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rules-card jifen-card">
|
|
<div class="border-title">
|
|
积分规则
|
|
</div>
|
|
<div v-loading="rulesLoading" class="rules">
|
|
<div v-for="(r, idx) in rules" :key="r.event" class="rule-row">
|
|
<div class="rule-left">
|
|
<img class="rule-icon" :src="ruleIcon(r.event)" alt="">
|
|
<span class="rule-event">{{ r.event }}</span>
|
|
</div>
|
|
<div class="rule-points" :class="Number(r.points) >= 0 ? 'pos' : 'neg'">
|
|
{{ Number(r.points) >= 0 ? "+" : "" }}{{ r.points }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 客户经理积分明细弹窗 -->
|
|
<el-dialog v-model="detailsVisible" class="manager-details-dialog" top="6vh" width="900px"
|
|
:close-on-click-modal="false" destroy-on-close @update:model-value="(v) => !v && (detailsVisible = false)"
|
|
@open="onDetailsOpen">
|
|
<div class="body-wrap">
|
|
<div class="body-header">
|
|
<span class="body-title">积分明细 - {{ currentManagerName }}</span>
|
|
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="detailsVisible = false" />
|
|
</div>
|
|
|
|
<el-table class="details-table" :data="detailsList" v-loading="detailsLoading" stripe>
|
|
<el-table-column prop="createdAt" label="时间" width="170" />
|
|
<el-table-column prop="source" label="来源" width="140" />
|
|
<el-table-column label="积分变动" width="120">
|
|
<template #default="{ row }">
|
|
<span class="change-amount" :class="Number(row.changeAmount) >= 0 ? 'pos' : 'neg'">
|
|
{{ Number(row.changeAmount) >= 0 ? "+" : "" }}{{ row.changeAmount }}
|
|
</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
|
|
</el-table>
|
|
|
|
<div class="admin-pagination">
|
|
<el-pagination v-model:current-page="detailsPage" v-model:page-size="detailsPageSize"
|
|
:page-sizes="[10, 20, 50]" :total="detailsTotal" layout="total, sizes, prev, pager, next, jumper"
|
|
@size-change="loadDetails" @current-change="loadDetails" />
|
|
</div>
|
|
</div>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { onMounted, ref } from "vue";
|
|
import { Coin, User, Document, CircleCheck } from "@element-plus/icons-vue";
|
|
import dayjs from "dayjs";
|
|
import serviceIcon from "@/assets/image/admin/score/service.svg";
|
|
import inspectorIcon from "@/assets/image/admin/score/inspector.svg";
|
|
import trainIcon from "@/assets/image/admin/score/train.svg";
|
|
import huifangIcon from "@/assets/image/admin/score/huifang.svg";
|
|
import complaintIcon from "@/assets/image/admin/score/complaint.svg";
|
|
import { dateRangeShortcuts } from "@/utils/dateShortcuts";
|
|
import {
|
|
getManagerPointsDetails,
|
|
getManagerPointsLeaderboard,
|
|
getManagerPointsRules,
|
|
getManagerPointsStats,
|
|
} from "@/service/modular/admin";
|
|
|
|
const periodTabs = [
|
|
{ label: "本周", value: "week" },
|
|
{ label: "本月", value: "month" },
|
|
{ label: "本季", value: "quarter" },
|
|
{ label: "本年", value: "year" },
|
|
];
|
|
|
|
const period = ref("month"); // month | quarter | year | weekly
|
|
const isCustom = ref(false);
|
|
const customRange = ref([]);
|
|
|
|
const stats = ref({});
|
|
const leaderboard = ref([]);
|
|
const rules = ref([]);
|
|
const leaderboardLoading = ref(false);
|
|
const rulesLoading = ref(false);
|
|
|
|
const statsLoading = ref(false);
|
|
|
|
const rankClass = (rank) => {
|
|
if (rank === 1) return "rank-1";
|
|
if (rank === 2) return "rank-2";
|
|
if (rank === 3) return "rank-3";
|
|
return "rank-other";
|
|
};
|
|
|
|
const ruleIconMap = {
|
|
'服务': "service",
|
|
'巡检': "inspector",
|
|
'培训': "train",
|
|
'回访': "huifang",
|
|
'投诉': "complaint",
|
|
};
|
|
const iconUrls = {
|
|
service: serviceIcon,
|
|
inspector: inspectorIcon,
|
|
train: trainIcon,
|
|
huifang: huifangIcon,
|
|
complaint: complaintIcon,
|
|
};
|
|
const ruleIcon = (event) => {
|
|
for (const [keyword, name] of Object.entries(ruleIconMap)) {
|
|
if (event?.includes(keyword)) {
|
|
return iconUrls[name];
|
|
}
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const getStatsParams = () => {
|
|
if (isCustom.value && customRange.value?.length === 2) {
|
|
return { start: customRange.value[0], end: customRange.value[1] };
|
|
}
|
|
return { period: period.value };
|
|
};
|
|
|
|
const paramsMap = {
|
|
week: 'weekly',
|
|
month: 'monthly',
|
|
quarter: 'quarterly',
|
|
year: 'annual'
|
|
}
|
|
|
|
const getLeaderboardParams = () => {
|
|
if (isCustom.value && customRange.value?.length === 2) {
|
|
return { start: customRange.value[0], end: customRange.value[1] };
|
|
}
|
|
return { period: paramsMap[period.value] };
|
|
};
|
|
|
|
const loadStats = async () => {
|
|
statsLoading.value = true;
|
|
try {
|
|
stats.value = (await getManagerPointsStats(getStatsParams())) || {};
|
|
} finally {
|
|
statsLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const loadLeaderboard = async () => {
|
|
leaderboardLoading.value = true;
|
|
try {
|
|
const res = await getManagerPointsLeaderboard(getLeaderboardParams());
|
|
leaderboard.value = Array.isArray(res) ? res : res?.list || [];
|
|
} finally {
|
|
leaderboardLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const loadRules = async () => {
|
|
rulesLoading.value = true;
|
|
try {
|
|
const res = await getManagerPointsRules();
|
|
rules.value = Array.isArray(res) ? res : res?.list || [];
|
|
} finally {
|
|
rulesLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const onPickPeriod = (val) => {
|
|
isCustom.value = false;
|
|
customRange.value = [];
|
|
period.value = val;
|
|
onSearch();
|
|
};
|
|
|
|
const onPickCustom = () => {
|
|
isCustom.value = true;
|
|
};
|
|
|
|
const onSearch = () => {
|
|
loadStats();
|
|
loadLeaderboard();
|
|
};
|
|
|
|
const onReset = () => {
|
|
period.value = "month";
|
|
isCustom.value = false;
|
|
customRange.value = [];
|
|
loadStats();
|
|
loadLeaderboard();
|
|
};
|
|
|
|
// 客户经理积分明细
|
|
const detailsVisible = ref(false);
|
|
const detailsLoading = ref(false);
|
|
const detailsList = ref([]);
|
|
const detailsTotal = ref(0);
|
|
const detailsPage = ref(1);
|
|
const detailsPageSize = ref(10);
|
|
const currentManagerId = ref("");
|
|
const currentManagerName = ref("");
|
|
|
|
const loadDetails = async () => {
|
|
if (!currentManagerId.value) return;
|
|
detailsLoading.value = true;
|
|
try {
|
|
const res = await getManagerPointsDetails(currentManagerId.value, {
|
|
page: detailsPage.value,
|
|
pageSize: detailsPageSize.value,
|
|
});
|
|
detailsList.value = (res?.list || []).map((item) => ({
|
|
...item,
|
|
createdAt: item.createdAt ? dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss") : "—",
|
|
}));
|
|
detailsTotal.value = res?.total || detailsList.value.length;
|
|
} finally {
|
|
detailsLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const onPickManager = (item) => {
|
|
currentManagerId.value = item.managerId;
|
|
currentManagerName.value = item.managerName || "—";
|
|
detailsPage.value = 1;
|
|
detailsVisible.value = true;
|
|
};
|
|
|
|
const onDetailsOpen = () => {
|
|
loadDetails();
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadStats();
|
|
loadLeaderboard();
|
|
loadRules();
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
@use "@/assets/style/admin-common.scss" as *;
|
|
|
|
.performance-score {
|
|
padding: 0;
|
|
|
|
.period-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
|
|
.period-tabs {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
|
|
.period-tab {
|
|
background: #FFFFFF !important;
|
|
border-radius: 8px 8px 8px 8px !important;
|
|
border: 1px solid #E0E0E0 !important;
|
|
font-weight: normal !important;
|
|
font-size: 16px !important;
|
|
color: #1D2129 !important;
|
|
line-height: 22px !important;
|
|
width: auto !important;
|
|
height: auto !important;
|
|
padding: 10px 24px !important;
|
|
|
|
&:hover,
|
|
&.active {
|
|
background: var(--brand-color) !important;
|
|
border-color: var(--brand-color) !important;
|
|
color: #fff !important;
|
|
}
|
|
}
|
|
|
|
.search-btn {
|
|
font-size: 16px !important;
|
|
// color: #1D2129 !important;
|
|
line-height: 22px !important;
|
|
width: auto !important;
|
|
height: auto !important;
|
|
padding: 10px 24px !important;
|
|
// color: var(--brand-color) !important;
|
|
|
|
// &:hover,
|
|
// &.active {
|
|
// background: var(--brand-color) !important;
|
|
// border-color: var(--brand-color) !important;
|
|
// color: #fff !important;
|
|
// }
|
|
}
|
|
|
|
|
|
|
|
.custom-range {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
|
|
.el-date-editor {
|
|
background: #FFFFFF !important;
|
|
border-radius: 8px 8px 8px 8px !important;
|
|
border: 1px solid #E0E0E0 !important;
|
|
overflow: hidden;
|
|
height: 44px !important;
|
|
}
|
|
}
|
|
}
|
|
|
|
.stat-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
|
|
.stat-card {
|
|
background: #FFFFFF;
|
|
border-radius: 16px 16px 16px 16px;
|
|
border: 1px solid #E0E0E0;
|
|
padding: 18px 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 18px;
|
|
}
|
|
|
|
.stat-icon {
|
|
width: 48px;
|
|
height: 48px;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.stat-body {
|
|
.stat-label {
|
|
font-weight: normal;
|
|
font-size: 14px;
|
|
color: #666666;
|
|
line-height: 20px;
|
|
}
|
|
|
|
.stat-value {
|
|
font-weight: bold;
|
|
font-size: 24px;
|
|
color: #1D2129;
|
|
line-height: 32px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.content-grid {
|
|
display: flex;
|
|
gap: 24px;
|
|
|
|
.jifen-card {
|
|
padding: 28px 24px 22px;
|
|
background: #FFFFFF;
|
|
border-radius: 16px 16px 16px 16px;
|
|
border: 1px solid #E0E0E0;
|
|
}
|
|
|
|
.leaderboard-card {
|
|
flex: 1;
|
|
|
|
.border-title {
|
|
margin-bottom: 26px;
|
|
}
|
|
}
|
|
|
|
.rules-card {
|
|
width: 560px;
|
|
|
|
.border-title {
|
|
margin-bottom: 16px;
|
|
}
|
|
}
|
|
|
|
|
|
.leaderboard {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
|
|
.lb-row {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
justify-content: space-between;
|
|
padding: 12px 16px;
|
|
background: #F9FAFF;
|
|
border-radius: 12px 12px 12px 12px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
|
|
// &:hover {
|
|
// background: #f0f7ff;
|
|
// border-color: #c0d8ff;
|
|
// }
|
|
}
|
|
|
|
.lb-rank {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
color: #3289FB;
|
|
|
|
flex-shrink: 0;
|
|
|
|
&.rank-1 {
|
|
background: linear-gradient(180deg, #B57000 0%, #EEC685 100%);
|
|
color: #fff;
|
|
}
|
|
|
|
&.rank-2 {
|
|
background: linear-gradient(180deg, #6F818E 0%, #BED8EA 100%);
|
|
color: #fff;
|
|
}
|
|
|
|
&.rank-3 {
|
|
background: linear-gradient(180deg, #AC6C55 0%, #C79888 100%);
|
|
color: #fff;
|
|
}
|
|
|
|
&.rank-other {
|
|
background: #EDF5FF;
|
|
color: #606266;
|
|
}
|
|
}
|
|
|
|
.lb-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
|
|
.lb-name {
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
color: #1D2129;
|
|
}
|
|
|
|
.lb-meta {
|
|
font-weight: normal;
|
|
font-size: 14px;
|
|
color: #666666;
|
|
|
|
.num {
|
|
color: #3289FB;
|
|
}
|
|
|
|
.sep {
|
|
margin: 0 4px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.lb-points {
|
|
flex-shrink: 0;
|
|
font-weight: bold;
|
|
font-size: 24px;
|
|
color: #6F818E;
|
|
}
|
|
}
|
|
|
|
.rules {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
|
|
.rule-row {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
|
|
background: #FFFFFF;
|
|
border-radius: 16px 16px 16px 16px;
|
|
border: 1px solid #E0E0E0;
|
|
}
|
|
|
|
.rule-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1px;
|
|
|
|
.rule-icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.rule-event {
|
|
font-weight: 400;
|
|
font-size: 14px;
|
|
color: #1D2129;
|
|
line-height: 18px;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.rule-points {
|
|
font-weight: 400;
|
|
font-size: 14px;
|
|
|
|
&.pos {
|
|
color: #34c759;
|
|
}
|
|
|
|
&.neg {
|
|
color: #ff383c;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<style lang="scss">
|
|
.manager-details-dialog {
|
|
.body-wrap {
|
|
padding: 4px 24px 16px;
|
|
}
|
|
|
|
.body-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
|
|
.body-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #1d2129;
|
|
}
|
|
|
|
.close-btn {
|
|
width: 16px;
|
|
height: 16px;
|
|
cursor: pointer;
|
|
}
|
|
}
|
|
|
|
.details-table {
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.change-amount {
|
|
font-weight: 600;
|
|
|
|
&.pos {
|
|
color: #34c759;
|
|
}
|
|
|
|
&.neg {
|
|
color: #ff383c;
|
|
}
|
|
}
|
|
|
|
.admin-pagination {
|
|
margin-top: 16px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
</style>
|