更新
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="layout-default flex h-screen w-full">
|
||||
<div class="app-aside">
|
||||
<!-- shrink-0:避免主内容区(宽表格/图表)把侧栏压缩到折叠宽度 -->
|
||||
<div class="app-aside shrink-0">
|
||||
<layout-sidebar />
|
||||
</div>
|
||||
|
||||
|
||||
+144
-311
@@ -2,25 +2,13 @@
|
||||
<div class="yeji-page">
|
||||
<header class="yeji-masthead">
|
||||
<div class="yeji-masthead__inner">
|
||||
<div class="yeji-masthead__text">
|
||||
<h1 class="yeji-masthead__title">业绩看板</h1>
|
||||
<p class="yeji-masthead__desc">
|
||||
按渠道、部门与日期查看部门业绩、医生统计与医助排行。下方可切换「统计图表 / 数据表格」;表格模式下点击数据行可在侧栏查看订单明细。
|
||||
</p>
|
||||
</div>
|
||||
<div class="yeji-masthead__meta" aria-hidden="true">
|
||||
<span class="yeji-masthead__dot" />
|
||||
<span class="yeji-masthead__line" />
|
||||
</div>
|
||||
<h1 class="yeji-masthead__title">业绩看板</h1>
|
||||
<p class="yeji-masthead__desc">渠道 · 部门 · 日期维度的业绩分析</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── 顶部筛选 ────────────────────────────── -->
|
||||
<el-card class="yeji-panel filter-card" shadow="never">
|
||||
<div class="yeji-panel__head">
|
||||
<span class="yeji-panel__eyebrow">筛选</span>
|
||||
<h2 class="yeji-panel__title">条件与快捷日期</h2>
|
||||
</div>
|
||||
<div class="filter-surface">
|
||||
<div class="filter-row filter-row--top">
|
||||
<div class="filter-field">
|
||||
@@ -115,11 +103,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="filter-hint">
|
||||
未选日期时展示「本月 / 本周 / 今日 / 昨日」四张汇总表;选日期后为单表。排行榜默认按今日(见下方说明)。<br />
|
||||
筛选展示部门时:所选节点会<strong>自动包含其下全部子部门</strong>并分行展示数据。<br />
|
||||
点击<strong>部门汇总表</strong>数据行:若有子部门则下钻为仅看该节点树下级;若为末级则在本页侧栏打开<strong>业务订单</strong>(创建时间、诊单医助部门与当前行一致)。<strong>医助排行榜</strong>行点击后按该医助在侧栏展示订单。
|
||||
</p>
|
||||
|
||||
|
||||
<div class="filter-quick">
|
||||
<span class="filter-quick__title">快捷日期</span>
|
||||
@@ -141,21 +125,20 @@
|
||||
<el-card class="yeji-panel yeji-main-tabs-card" shadow="never">
|
||||
<el-tabs v-model="yejiDisplayTab" class="yeji-view-tabs">
|
||||
<el-tab-pane label="统计图表" name="charts" lazy>
|
||||
<p class="yeji-charts-intro">
|
||||
与上方筛选联动。除对比柱形外,补充<strong>漏斗</strong>(进线→成交)、<strong>环形占比</strong>、<strong>散点分布</strong>与<strong>雷达对比</strong>等视图,便于看结构、分布与多维差异;明细仍以「数据表格」为准。
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-loading="loading || doctorDailyLoading || leaderboardsLoading"
|
||||
v-loading="loading || (canViewDoctorDailyStats && doctorDailyLoading) || leaderboardsLoading"
|
||||
class="yeji-charts-stack"
|
||||
>
|
||||
<template v-if="chartsHasAnyData">
|
||||
<section
|
||||
v-if="
|
||||
doctorDealBarHasData ||
|
||||
doctorRxStackHasData ||
|
||||
doctorAppointmentPieHasData ||
|
||||
doctorScatterHasData ||
|
||||
doctorRadarHasData
|
||||
canViewDoctorDailyStats &&
|
||||
(doctorDealBarHasData ||
|
||||
doctorRxStackHasData ||
|
||||
doctorAppointmentPieHasData ||
|
||||
doctorScatterHasData ||
|
||||
doctorRadarHasData)
|
||||
"
|
||||
class="yeji-chart-block"
|
||||
>
|
||||
@@ -218,7 +201,7 @@
|
||||
<el-row :gutter="16" class="yeji-chart-subrow">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<div class="yeji-chart-panel">
|
||||
<div class="yeji-chart-panel__label">成交单数 × 金额(散点)</div>
|
||||
<div class="yeji-chart-panel__label">接诊诊单 × 金额(散点)</div>
|
||||
<v-charts
|
||||
v-if="doctorScatterHasData"
|
||||
class="yeji-echart yeji-echart--compact"
|
||||
@@ -227,7 +210,7 @@
|
||||
/>
|
||||
<el-empty
|
||||
v-else
|
||||
description="至少两名医生同时有成交单与金额时可展示"
|
||||
description="至少两名医生同时有接诊诊单与金额时可展示"
|
||||
:image-size="72"
|
||||
/>
|
||||
</div>
|
||||
@@ -334,7 +317,9 @@
|
||||
</template>
|
||||
<el-empty
|
||||
v-else-if="
|
||||
!loading && !doctorDailyLoading && !leaderboardsLoading
|
||||
!loading &&
|
||||
(!canViewDoctorDailyStats || !doctorDailyLoading) &&
|
||||
!leaderboardsLoading
|
||||
"
|
||||
class="yeji-charts-empty"
|
||||
description="当前筛选下暂无图表数据,请调整条件后查询"
|
||||
@@ -343,12 +328,15 @@
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="数据表格" name="table">
|
||||
<!-- 医生统计:渠道、日期与顶部联动;展示部门不参与 -->
|
||||
<el-card class="yeji-panel doctor-daily-card doctor-daily-card--nested" shadow="never">
|
||||
<!-- 医生统计:渠道、日期与顶部联动;展示部门不参与(需接口权限) -->
|
||||
<el-card
|
||||
v-if="canViewDoctorDailyStats"
|
||||
class="yeji-panel doctor-daily-card doctor-daily-card--nested"
|
||||
shadow="never"
|
||||
>
|
||||
<template #header>
|
||||
<div class="doctor-daily-card__head">
|
||||
<div class="doctor-daily-card__title-block">
|
||||
<span class="yeji-panel__eyebrow yeji-panel__eyebrow--in-header">数据</span>
|
||||
<span class="doctor-daily-card__title">医生统计</span>
|
||||
<span v-if="doctorDailyRangeText" class="doctor-daily-card__range">{{
|
||||
doctorDailyRangeText
|
||||
@@ -366,11 +354,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="doctor-daily-card__hint">
|
||||
<strong>展示部门</strong>筛选不参与本表。与<strong>渠道来源</strong>、<strong>日期范围</strong>一致(含账号数据权限);未选日期范围时段为<b>今日</b>。列出
|
||||
<b>医生角色(role_id=1)</b>且在您数据范围内的账号;开方按处方 <b>prescription_date</b>;成交按订单
|
||||
<b>create_time</b>;挂号按 <b>appointment_date</b>;选渠道时口径与业绩看板渠道列对齐。
|
||||
</p>
|
||||
<el-table
|
||||
v-loading="doctorDailyLoading"
|
||||
:data="doctorDailyRows"
|
||||
@@ -397,7 +380,7 @@
|
||||
<el-table-column prop="deal_amount" label="成交金额" min-width="110" align="right">
|
||||
<template #default="{ row }">¥ {{ formatDoctorDailyMoney(row.deal_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deal_order_count" label="成交单数" min-width="88" align="right" />
|
||||
<el-table-column prop="deal_order_count" label="接诊诊单" min-width="88" align="right" />
|
||||
<el-table-column prop="avg_deal_amount" label="平均单价" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
{{
|
||||
@@ -530,7 +513,7 @@
|
||||
</el-tooltip>
|
||||
</th>
|
||||
<th>
|
||||
<span>接诊诊单</span>
|
||||
<span>预约诊单</span>
|
||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||
<template #content>
|
||||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||||
@@ -542,7 +525,7 @@
|
||||
</el-tooltip>
|
||||
</th>
|
||||
<th>
|
||||
<span>成交单数</span>
|
||||
<span>接诊诊单</span>
|
||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||
<template #content>
|
||||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||||
@@ -571,7 +554,7 @@
|
||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||
<template #content>
|
||||
<div style="max-width: 280px; line-height: 1.7; font-size: 12px">
|
||||
{{ tb.channel_name }}业绩 ÷ {{ tb.channel_name }}成交单数(均值)。
|
||||
{{ tb.channel_name }}业绩 ÷ {{ tb.channel_name }}接诊诊单(均值)。
|
||||
</div>
|
||||
</template>
|
||||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||||
@@ -664,10 +647,7 @@
|
||||
<p class="leaderboard-hint">
|
||||
{{ leaderboardBlock.range_note }}
|
||||
<span class="muted">
|
||||
统计区间:{{ leaderboardBlock.start_date }}~{{ leaderboardBlock.end_date }}
|
||||
<template v-if="!customRange">
|
||||
(未选自定义日期时排行榜仅统计今日,与上方多张业绩表区间无关)
|
||||
</template>
|
||||
{{ leaderboardBlock.start_date }} ~ {{ leaderboardBlock.end_date }}
|
||||
</span>
|
||||
</p>
|
||||
<div class="tables-wrap">
|
||||
@@ -686,7 +666,7 @@
|
||||
<th class="col-abbr">部门</th>
|
||||
<th class="col-name">姓名</th>
|
||||
<th class="col-consult">接诊单数</th>
|
||||
<th class="col-deal">成交单数</th>
|
||||
<th class="col-deal">接诊诊单</th>
|
||||
<th>诊金</th>
|
||||
<th>接诊率<br /><span class="th-sub">(元/进线)</span></th>
|
||||
</tr>
|
||||
@@ -709,12 +689,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="yeji-table__foot yeji-table__foot--left">
|
||||
<span
|
||||
>诊金单位:元 · 接诊单数 = 统计区间内已完成挂号(与部门表「接诊诊单」同口径,按
|
||||
appointment_date;有效医助 = 挂号或诊单医助;选定标签类渠道时与排行诊金同源收窄)·
|
||||
成交单数 = 非取消业务订单条数(仅剔除履约已取消),按订单 create_time、诊单医助 dg.assistant_id,与部门表「成交单数」及排行诊金同源
|
||||
· 接诊率 = 诊金 ÷ 进线(元/进线,保留 1 位小数;有渠道时进线含标签过滤与二中心规则)</span
|
||||
>
|
||||
<span>诊金单位:元 · 接诊率 = 诊金 ÷ 进线(元/进线)</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -854,6 +829,7 @@ import {
|
||||
} from '@/api/stats'
|
||||
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||||
import { prescriptionOrderLists } from '@/api/tcm'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
|
||||
interface DeptOption {
|
||||
id: number
|
||||
@@ -1011,6 +987,14 @@ const leaderboardsLoading = ref(false)
|
||||
/** 图表 / 数据表切换 */
|
||||
const yejiDisplayTab = ref<'charts' | 'table'>('table')
|
||||
|
||||
/** 与菜单 stats.doctorDailyStats/overview 一致;无权限则不展示医生统计表/图、不请求接口 */
|
||||
const userStore = useUserStore()
|
||||
const DOCTOR_DAILY_STATS_PERM = 'stats.doctorDailyStats/overview'
|
||||
const canViewDoctorDailyStats = computed(() => {
|
||||
const perms = userStore.perms || []
|
||||
return perms.some(p => p === '*' || p === DOCTOR_DAILY_STATS_PERM)
|
||||
})
|
||||
|
||||
const leaderboardChartBlocks = computed(() =>
|
||||
(leaderboardBlock.value?.leaderboards ?? []).filter(lb => lb.rows.length > 0)
|
||||
)
|
||||
@@ -1052,9 +1036,11 @@ const doctorRxStackHasData = computed(() =>
|
||||
)
|
||||
|
||||
const chartsHasAnyData = computed(() => {
|
||||
if (doctorDealBarHasData.value || doctorRxStackHasData.value) return true
|
||||
if (doctorAppointmentPieHasData.value || doctorScatterHasData.value || doctorRadarHasData.value)
|
||||
return true
|
||||
if (canViewDoctorDailyStats.value) {
|
||||
if (doctorDealBarHasData.value || doctorRxStackHasData.value) return true
|
||||
if (doctorAppointmentPieHasData.value || doctorScatterHasData.value || doctorRadarHasData.value)
|
||||
return true
|
||||
}
|
||||
for (const tb of tables.value) {
|
||||
if (yejiDeptChartHasData(tb)) return true
|
||||
}
|
||||
@@ -1125,13 +1111,13 @@ const doctorScatterOption = computed(() => {
|
||||
formatter: (p: any) => {
|
||||
const v = p.data?.value as number[] | undefined
|
||||
if (!Array.isArray(v)) return ''
|
||||
return `${p.data.name as string}<br/>成交单数:${v[0]}<br/>金额:${Number(v[1]).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
|
||||
return `${p.data.name as string}<br/>接诊诊单:${v[0]}<br/>金额:${Number(v[1]).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
|
||||
},
|
||||
},
|
||||
grid: { left: 48, right: 24, top: 28, bottom: 32, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
name: '成交单数',
|
||||
name: '接诊诊单',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 28,
|
||||
splitLine: { lineStyle: { type: 'dashed', opacity: 0.55 } },
|
||||
@@ -1215,7 +1201,7 @@ const doctorRadarOption = computed(() => {
|
||||
radar: {
|
||||
indicator: [
|
||||
{ name: '成交金额', max: 100 },
|
||||
{ name: '成交单数', max: 100 },
|
||||
{ name: '接诊诊单', max: 100 },
|
||||
{ name: '开方量', max: 100 },
|
||||
{ name: '挂号完成', max: 100 },
|
||||
{ name: '客单价', max: 100 },
|
||||
@@ -1441,7 +1427,7 @@ const orderDrawerTotalAmount = computed(() => {
|
||||
return Number.isFinite(n) ? n : null
|
||||
})
|
||||
|
||||
/** 约诊/接诊条数(与业绩看板「接诊诊单」同口径,来自列表 extend) */
|
||||
/** 约诊/接诊条数(与业绩看板「预约诊单」同口径,来自列表 extend) */
|
||||
const orderDrawerConsultCount = computed(() => {
|
||||
const ex = orderDrawerExtend.value
|
||||
if (!ex || ex.stats_yeji_consult_count === undefined || ex.stats_yeji_consult_count === null) {
|
||||
@@ -1802,7 +1788,13 @@ async function loadData() {
|
||||
ElMessage.error(e?.msg || e?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
void loadDoctorDailyStats()
|
||||
if (canViewDoctorDailyStats.value) {
|
||||
void loadDoctorDailyStats()
|
||||
} else {
|
||||
doctorDailyRows.value = []
|
||||
doctorDailyTotal.value = {}
|
||||
doctorDailyRange.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2294,113 +2286,67 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500;600;700&display=swap');
|
||||
/* ═══════════════════════════════════════════════
|
||||
业绩看板 — Clean Modern Dashboard
|
||||
═══════════════════════════════════════════════ */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
.yeji-page {
|
||||
/* 克制医疗数据看板:浅底、细线、单一品牌强调色 */
|
||||
--yj-brand: #2563eb;
|
||||
--yj-brand-soft: #eff6ff;
|
||||
--yj-teal: #0d9488;
|
||||
--yj-teal-soft: #ecfdf5;
|
||||
--yj-accent: #d97706;
|
||||
--yj-accent-soft: #fffbeb;
|
||||
--yj-ink: #0f172a;
|
||||
--yj-ink-secondary: #334155;
|
||||
min-width: 0;
|
||||
--yj-brand: #6366f1;
|
||||
--yj-brand-soft: #eef2ff;
|
||||
--yj-teal: #0ea5e9;
|
||||
--yj-teal-soft: #f0f9ff;
|
||||
--yj-accent: #f43f5e;
|
||||
--yj-accent-soft: #fff1f2;
|
||||
--yj-ink: #1e293b;
|
||||
--yj-ink-secondary: #475569;
|
||||
--yj-muted: #64748b;
|
||||
--yj-line: #e2e8f0;
|
||||
--yj-line-strong: #cbd5e1;
|
||||
--yj-surface: #ffffff;
|
||||
--yj-surface-muted: #f8fafc;
|
||||
--yj-page: #eceff3;
|
||||
--yj-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
--yj-shadow-md: 0 4px 20px -4px rgba(15, 23, 42, 0.08);
|
||||
--yj-radius: 14px;
|
||||
--yj-page: #f1f5f9;
|
||||
--yj-shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
|
||||
--yj-shadow-md: 0 4px 16px rgba(0,0,0,0.06);
|
||||
--yj-radius: 16px;
|
||||
--yj-radius-sm: 10px;
|
||||
--yj-font: 'Fira Sans', var(--el-font-family), system-ui, sans-serif;
|
||||
--yj-font: 'Inter', var(--el-font-family), system-ui, sans-serif;
|
||||
|
||||
min-height: 100%;
|
||||
padding: 20px 22px 32px;
|
||||
padding: 24px;
|
||||
font-family: var(--yj-font);
|
||||
color: var(--yj-ink);
|
||||
background: var(--yj-page);
|
||||
}
|
||||
|
||||
.yeji-masthead {
|
||||
margin-bottom: 22px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&__inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 22px 24px 22px 20px;
|
||||
background: var(--yj-surface);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 28px 32px;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
border-radius: var(--yj-radius);
|
||||
border: 1px solid var(--yj-line);
|
||||
box-shadow: var(--yj-shadow-sm);
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, var(--yj-brand) 0%, #38bdf8 100%);
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 78ch;
|
||||
padding-left: 12px;
|
||||
box-shadow: var(--yj-shadow-md);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--yj-ink);
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.65;
|
||||
color: var(--yj-muted);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--yj-line);
|
||||
|
||||
@media (min-width: 900px) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--yj-teal);
|
||||
box-shadow: 0 0 0 6px var(--yj-teal-soft);
|
||||
}
|
||||
|
||||
&__line {
|
||||
width: 1px;
|
||||
height: 48px;
|
||||
margin-top: 12px;
|
||||
background: linear-gradient(180deg, transparent, var(--yj-line-strong), transparent);
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2414,41 +2360,11 @@ onMounted(async () => {
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__head {
|
||||
padding: 18px 22px 14px;
|
||||
background: linear-gradient(180deg, #fafbfc 0%, var(--yj-surface) 100%);
|
||||
border-bottom: 1px solid var(--yj-line);
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__eyebrow {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--yj-brand);
|
||||
|
||||
&--in-header {
|
||||
margin-bottom: 6px;
|
||||
color: var(--yj-brand);
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: var(--yj-ink);
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
overflow: hidden;
|
||||
border-radius: var(--yj-radius);
|
||||
}
|
||||
|
||||
.yeji-main-tabs-card {
|
||||
@@ -2463,63 +2379,42 @@ onMounted(async () => {
|
||||
.yeji-view-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
padding: 12px 16px 0;
|
||||
padding: 0 20px;
|
||||
background: var(--yj-surface-muted);
|
||||
border-bottom: 1px solid var(--yj-line);
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) { display: none; }
|
||||
:deep(.el-tabs__nav) { border: none; }
|
||||
:deep(.el-tabs__item) {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
margin-right: 4px;
|
||||
border-radius: 999px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
padding: 0 20px;
|
||||
color: var(--yj-muted);
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--yj-brand);
|
||||
}
|
||||
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
&:hover { color: var(--yj-ink); }
|
||||
&.is-active {
|
||||
color: var(--yj-brand);
|
||||
background: var(--yj-brand-soft);
|
||||
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--yj-brand);
|
||||
outline-offset: 2px;
|
||||
border-bottom-color: var(--yj-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tabs__active-bar) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__active-bar) { display: none; }
|
||||
:deep(.el-tabs__content) {
|
||||
padding: 20px 20px 0;
|
||||
background: var(--yj-surface);
|
||||
padding: 20px;
|
||||
overflow: visible;
|
||||
}
|
||||
:deep(.el-tab-pane) { overflow: visible; }
|
||||
}
|
||||
|
||||
.yeji-charts-intro {
|
||||
margin: 0 0 18px;
|
||||
padding: 14px 16px;
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: var(--yj-ink-secondary);
|
||||
line-height: 1.6;
|
||||
color: var(--yj-muted);
|
||||
background: var(--yj-surface-muted);
|
||||
border-radius: var(--yj-radius-sm);
|
||||
border: 1px solid var(--yj-line);
|
||||
@@ -2528,68 +2423,48 @@ onMounted(async () => {
|
||||
.yeji-charts-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 20px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.yeji-charts-empty {
|
||||
padding: 48px 16px;
|
||||
}
|
||||
.yeji-charts-empty { padding: 48px 16px; }
|
||||
|
||||
.yeji-chart-block {
|
||||
padding: 18px;
|
||||
background: var(--yj-surface-muted);
|
||||
padding: 20px;
|
||||
background: var(--yj-surface);
|
||||
border-radius: var(--yj-radius-sm);
|
||||
border: 1px solid var(--yj-line);
|
||||
|
||||
&__title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.9375rem;
|
||||
margin: 0 0 16px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--yj-ink);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 14px;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--yj-teal);
|
||||
}
|
||||
|
||||
&__sub--muted {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--yj-muted);
|
||||
gap: 8px 12px;
|
||||
}
|
||||
&__sub { font-size: 12px; font-weight: 600; color: var(--yj-teal); }
|
||||
&__sub--muted { font-size: 11px; font-weight: 500; color: var(--yj-muted); }
|
||||
}
|
||||
|
||||
.yeji-chart-subrow {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.yeji-chart-subrow { margin-top: 4px; }
|
||||
|
||||
.yeji-chart-panel {
|
||||
border: 1px solid var(--yj-line);
|
||||
border-radius: var(--yj-radius-sm);
|
||||
padding: 14px 14px 10px;
|
||||
padding: 14px;
|
||||
background: var(--yj-surface);
|
||||
height: 100%;
|
||||
box-shadow: var(--yj-shadow-sm);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
transition: box-shadow 0.2s;
|
||||
&:hover { box-shadow: var(--yj-shadow-sm); }
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--yj-muted);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2705,7 +2580,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.filter-surface {
|
||||
padding: 18px 22px 20px;
|
||||
padding: 20px;
|
||||
background: var(--yj-surface);
|
||||
}
|
||||
|
||||
@@ -2770,14 +2645,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.filter-hint {
|
||||
margin: 16px 0 0;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: var(--yj-ink-secondary);
|
||||
background: var(--yj-surface-muted);
|
||||
border-radius: var(--yj-radius-sm);
|
||||
border: 1px solid var(--yj-line);
|
||||
display: none; /* 已在模板中移除,保留class以免报错 */
|
||||
}
|
||||
|
||||
.filter-quick {
|
||||
@@ -2812,29 +2680,16 @@ onMounted(async () => {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 8px 15px;
|
||||
border-radius: 999px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
line-height: 1.35;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
line-height: 1.4;
|
||||
transition: all 0.15s ease;
|
||||
&:hover {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
border-color: var(--yj-brand);
|
||||
color: var(--yj-brand);
|
||||
background: var(--yj-brand-soft);
|
||||
box-shadow: var(--yj-shadow-sm);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--yj-brand);
|
||||
outline-offset: 2px;
|
||||
@@ -2851,38 +2706,21 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 10px 22px;
|
||||
padding: 8px 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
border-radius: var(--yj-radius-sm);
|
||||
|
||||
&__icon {
|
||||
margin-right: 6px;
|
||||
font-size: 16px;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
&__icon { margin-right: 4px; font-size: 15px; vertical-align: -2px; }
|
||||
&--query {
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.22);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
background: var(--yj-brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
&:hover { opacity: 0.9; color: #fff; }
|
||||
}
|
||||
|
||||
&--reset {
|
||||
border: 1px solid var(--yj-line);
|
||||
background: var(--yj-surface);
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
color: var(--yj-brand);
|
||||
background: var(--yj-brand-soft);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
&:hover { border-color: var(--yj-brand); color: var(--yj-brand); }
|
||||
&:disabled { opacity: 0.5; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2963,10 +2801,10 @@ onMounted(async () => {
|
||||
background: var(--yj-surface-muted);
|
||||
color: var(--yj-ink);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 13px 20px;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 14px 20px;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid var(--yj-line);
|
||||
}
|
||||
|
||||
@@ -3123,8 +2961,7 @@ onMounted(async () => {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 13px 16px;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--yj-line);
|
||||
}
|
||||
|
||||
@@ -3136,25 +2973,21 @@ onMounted(async () => {
|
||||
color: var(--yj-ink);
|
||||
|
||||
thead th {
|
||||
background: #eef2f7;
|
||||
background: #f1f5f9;
|
||||
color: var(--yj-ink-secondary);
|
||||
font-weight: 600;
|
||||
padding: 11px 8px;
|
||||
padding: 10px 8px;
|
||||
border: 1px solid var(--yj-line);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
|
||||
.col-info {
|
||||
margin-left: 4px;
|
||||
color: var(--yj-muted);
|
||||
cursor: help;
|
||||
vertical-align: -2px;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--yj-brand);
|
||||
}
|
||||
transition: color 0.15s;
|
||||
&:hover { color: var(--yj-brand); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="toolbar__title">制定业绩目标</div>
|
||||
<div class="toolbar__desc">按部门设置每月销售目标,填 0 并保存将删除该记录</div>
|
||||
<div class="toolbar__desc">
|
||||
按部门设置每月销售目标,填 0 并保存将删除该记录。列表按当前账号
|
||||
<strong>角色数据权限</strong>
|
||||
仅展示<strong>本部门及下级</strong>(不展示权限外的上级组织;口径与业绩看板数据范围一致)。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar__center">
|
||||
@@ -59,8 +63,16 @@
|
||||
|
||||
<div v-if="!loading && formRows.length === 0" class="empty-state">
|
||||
<div class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
|
||||
<p class="empty-state__title">暂无部门数据</p>
|
||||
<p class="empty-state__desc">请检查部门配置后重试</p>
|
||||
<p class="empty-state__title">
|
||||
{{ dataScopeLimited ? '数据权限内无可选部门' : '暂无部门数据' }}
|
||||
</p>
|
||||
<p class="empty-state__desc">
|
||||
{{
|
||||
dataScopeLimited
|
||||
? '当前角色数据范围未包含任何部门,或需在权限中绑定部门后再试。'
|
||||
: '请检查部门配置后重试'
|
||||
}}
|
||||
</p>
|
||||
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +173,8 @@ const yearMonth = ref<string>(ymNow())
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRows = ref<Row[]>([])
|
||||
/** 接口返回:当前账号是否受数据权限收窄(非「全部」等) */
|
||||
const dataScopeLimited = ref(false)
|
||||
|
||||
// 钻取卡片栈:每次 drill-in 追加,close 时移除及其后续
|
||||
const drillStack = ref<Row[]>([])
|
||||
@@ -199,8 +213,10 @@ async function loadMatrix() {
|
||||
try {
|
||||
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
|
||||
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
|
||||
dataScopeLimited.value = Boolean(res?.data_scope_limited)
|
||||
} catch {
|
||||
formRows.value = []
|
||||
dataScopeLimited.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -217,6 +233,7 @@ async function handleSave() {
|
||||
})
|
||||
if (res?.rows) {
|
||||
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
|
||||
dataScopeLimited.value = Boolean(res?.data_scope_limited)
|
||||
drillStack.value = []
|
||||
drilledId.value = null
|
||||
} else {
|
||||
|
||||
@@ -21,7 +21,7 @@ class DeptPerformanceTargetController extends BaseAdminController
|
||||
$params = (new DeptPerformanceTargetValidate())->goCheck('monthMatrix');
|
||||
$ym = (string) $params['year_month'];
|
||||
|
||||
return $this->data(DeptPerformanceTargetLogic::monthMatrix($ym));
|
||||
return $this->data(DeptPerformanceTargetLogic::monthMatrix($ym, $this->adminId, $this->adminInfo));
|
||||
}
|
||||
|
||||
public function batchSave()
|
||||
@@ -37,12 +37,18 @@ class DeptPerformanceTargetController extends BaseAdminController
|
||||
$ym,
|
||||
$items,
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
(string) ($this->adminInfo['name'] ?? '')
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(DeptPerformanceTargetLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('保存成功', DeptPerformanceTargetLogic::monthMatrix($ym), 1, 1);
|
||||
return $this->success(
|
||||
'保存成功',
|
||||
DeptPerformanceTargetLogic::monthMatrix($ym, $this->adminId, $this->adminInfo),
|
||||
1,
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\dept\Dept;
|
||||
use app\common\model\finance\DeptPerformanceTarget;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
class DeptPerformanceTargetLogic extends BaseLogic
|
||||
@@ -15,11 +16,16 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
||||
/**
|
||||
* 指定月份:保留部门树层级 + 合并当月已有目标
|
||||
*
|
||||
* @return array{year_month: string, rows: array<int, array<string, mixed>>, total_target: float}
|
||||
* @return array{year_month: string, rows: array<int, array<string, mixed>>, total_target: float, data_scope_limited: bool}
|
||||
*/
|
||||
public static function monthMatrix(string $yearMonth): array
|
||||
public static function monthMatrix(string $yearMonth, int $adminId = 0, array $adminInfo = []): array
|
||||
{
|
||||
$tree = DeptLogic::getAllData();
|
||||
$allowed = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
$scoped = $allowed !== null;
|
||||
if ($scoped) {
|
||||
$tree = self::filterDeptTreeAllowedForest(is_array($tree) ? $tree : [], $allowed);
|
||||
}
|
||||
|
||||
$rowsDb = DeptPerformanceTarget::where('year_month', $yearMonth)
|
||||
->select()
|
||||
@@ -33,19 +39,22 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
||||
$total = self::sumTargetsInTree($rows);
|
||||
|
||||
return [
|
||||
'year_month' => $yearMonth,
|
||||
'rows' => $rows,
|
||||
'total_target' => round($total, 2),
|
||||
'year_month' => $yearMonth,
|
||||
'rows' => $rows,
|
||||
'total_target' => round($total, 2),
|
||||
'data_scope_limited' => $scoped,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{dept_id?:int|float|string, target_amount?:int|float|string, remark?:string}> $items
|
||||
*/
|
||||
public static function batchSave(string $yearMonth, array $items, int $adminId, string $adminName): bool
|
||||
public static function batchSave(string $yearMonth, array $items, int $adminId, array $adminInfo, string $adminName): bool
|
||||
{
|
||||
$allowed = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
|
||||
try {
|
||||
Db::transaction(function () use ($yearMonth, $items, $adminId, $adminName): void {
|
||||
Db::transaction(function () use ($yearMonth, $items, $adminId, $adminName, $allowed): void {
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
@@ -54,6 +63,10 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
||||
if ($deptId <= 0) {
|
||||
continue;
|
||||
}
|
||||
// 仅处理 data scope 内的 dept_id(界面树与 monthMatrix 一致,不再含纯展示用父级)
|
||||
if ($allowed !== null && !isset($allowed[$deptId])) {
|
||||
continue;
|
||||
}
|
||||
$dept = Dept::where('id', $deptId)->whereNull('delete_time')->find();
|
||||
if (!$dept) {
|
||||
continue;
|
||||
@@ -162,4 +175,63 @@ class DeptPerformanceTargetLogic extends BaseLogic
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本部门及以下:仅展示 allowed 内节点;无权父级不渲染,从子树中接续(不露出公司/上级中心)。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $nodes
|
||||
* @param array<int, true> $allowedMap
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function filterDeptTreeAllowedForest(array $nodes, array $allowedMap): array
|
||||
{
|
||||
if ($allowedMap === []) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($nodes as $n) {
|
||||
if (!is_array($n)) {
|
||||
continue;
|
||||
}
|
||||
$id = (int) ($n['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rawChildren = $n['children'] ?? [];
|
||||
$kids = is_array($rawChildren) ? $rawChildren : [];
|
||||
|
||||
if (isset($allowedMap[$id])) {
|
||||
$childList = [];
|
||||
foreach ($kids as $c) {
|
||||
if (!is_array($c)) {
|
||||
continue;
|
||||
}
|
||||
foreach (self::filterDeptTreeAllowedForest([$c], $allowedMap) as $item) {
|
||||
$childList[] = $item;
|
||||
}
|
||||
}
|
||||
$node = $n;
|
||||
if ($childList !== []) {
|
||||
$node['children'] = $childList;
|
||||
} else {
|
||||
unset($node['children']);
|
||||
}
|
||||
$out[] = $node;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($kids as $c) {
|
||||
if (!is_array($c)) {
|
||||
continue;
|
||||
}
|
||||
foreach (self::filterDeptTreeAllowedForest([$c], $allowedMap) as $item) {
|
||||
$out[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,34 +672,7 @@ class YejiStatsLogic
|
||||
*/
|
||||
private static function buildYejiDataScopeAllowedDeptIdMap(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
if ($adminId <= 0 || !DataScopeService::isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === null) {
|
||||
return null;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$set = [];
|
||||
foreach ($visibleIds as $aid) {
|
||||
$deptRows = Db::name('admin_dept')->where('admin_id', (int) $aid)->column('dept_id');
|
||||
foreach ($deptRows as $d) {
|
||||
$d = (int) $d;
|
||||
if ($d <= 0) {
|
||||
continue;
|
||||
}
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($d) as $x) {
|
||||
$x = (int) $x;
|
||||
if ($x > 0) {
|
||||
$set[$x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $set;
|
||||
return DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -170,6 +170,43 @@ class DataScopeService
|
||||
return self::getEffectiveScope($adminInfo) === self::SCOPE_ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据范围下:可见成员所在部门及其下级部门 id(与业绩看板 deptOptions、部门类下拉收窄一致)。
|
||||
*
|
||||
* @return array<int, true>|null null 表示不限制;[] 表示无可选部门
|
||||
*/
|
||||
public static function getAllowedDeptIdSet(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
if ($adminId <= 0 || !self::isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
$visibleIds = self::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === null) {
|
||||
return null;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$set = [];
|
||||
foreach ($visibleIds as $aid) {
|
||||
$deptRows = AdminDept::where('admin_id', (int) $aid)->column('dept_id');
|
||||
foreach ($deptRows as $d) {
|
||||
$d = (int) $d;
|
||||
if ($d <= 0) {
|
||||
continue;
|
||||
}
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($d) as $x) {
|
||||
$x = (int) $x;
|
||||
if ($x > 0) {
|
||||
$set[$x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字描述(日志 / 接口返回可选使用)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user