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

<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>