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