更新bug

This commit is contained in:
Your Name
2026-08-17 09:06:49 +08:00
parent 2fc8048844
commit c1eee2d28a
48 changed files with 3646 additions and 1016 deletions
+105 -79
View File
@@ -103,40 +103,40 @@
</article> </article>
</section> </section>
<section class="ranking-grid"> <section v-if="showRankings" class="ranking-grid">
<article class="panel ranking-panel"> <article class="panel ranking-panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>部门订单量占比</h2> <h2>{{ rankingSubject }}订单量占比排名</h2>
<p>订单创建人归属排除取消拒收及退款</p> <p>{{ rankingSubject }}归属统计排除取消拒收及退款</p>
</div> </div>
<span>单位</span> <span>单位</span>
</div> </div>
<div v-if="dashboard.rankings.orders.length" class="bar-list"> <div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="item in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row"> <div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name">{{ item.name }}</span> <span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, maxOrderValue) }" /></div> <div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
<strong>{{ formatNumber(item.value) }} </strong> <strong><span>{{ formatNumber(item.value) }} </span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
</div> </div>
</div> </div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" /> <el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
</article> </article>
<article class="panel ranking-panel"> <article class="panel ranking-panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h2>部门金额占比</h2> <h2>{{ rankingSubject }}金额占比排名</h2>
<p>仅统计未取消未拒收且未退款的有效金额</p> <p>{{ rankingSubject }}归属统计仅含未取消未拒收且未退款的有效金额</p>
</div> </div>
<span>单位</span> <span>单位</span>
</div> </div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list"> <div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="item in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row"> <div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name">{{ item.name }}</span> <span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmountValue) }" /></div> <div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
<strong>{{ formatMoney(item.value) }}</strong> <strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
</div> </div>
</div> </div>
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" /> <el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
</article> </article>
</section> </section>
@@ -156,7 +156,7 @@
default-expand-all default-expand-all
class="detail-table" class="detail-table"
> >
<el-table-column prop="name" label="部门 / 人员" min-width="280" fixed="left"> <el-table-column prop="name" label="部门 / 人员" min-width="220" fixed="left">
<template #default="{ row }"> <template #default="{ row }">
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }"> <strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
{{ row.name }} {{ row.name }}
@@ -178,34 +178,34 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" /> <el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" /> <el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="88" align="right" /> <el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
<el-table-column prop="appointment_total_count" label="预约" min-width="88" align="right" /> <el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" /> <el-table-column prop="interview_count" label="面诊" min-width="72" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" /> <el-table-column prop="completed_order_count" label="接诊诊单" min-width="88" align="right" />
<el-table-column label="接诊金额" min-width="120" align="right"> <el-table-column label="接诊金额" min-width="104" align="right">
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template> <template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="开口率" min-width="96" align="right"> <el-table-column label="开口率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template> <template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="挂号率" min-width="96" align="right"> <el-table-column label="挂号率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template> <template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="面诊率" min-width="96" align="right"> <el-table-column label="面诊率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template> <template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="预约率" min-width="96" align="right"> <el-table-column label="预约率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template> <template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right"> <el-table-column label="面诊接诊率" min-width="100" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template> <template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="接诊率" min-width="96" align="right"> <el-table-column label="接诊率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template> <template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="ROI" min-width="86" align="right"> <el-table-column label="ROI" min-width="72" align="right">
<template #default="{ row }">{{ formatRatio(row.roi) }}</template> <template #default="{ row }">{{ formatRatio(row.roi) }}</template>
</el-table-column> </el-table-column>
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template> <template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
@@ -291,9 +291,9 @@ type MediaChannelOption = {
const emptyDashboard = () => ({ const emptyDashboard = () => ({
meta: { meta: {
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '', time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '', scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '' selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden'
}, },
filters: { filters: {
departments: [] as any[], departments: [] as any[],
@@ -364,8 +364,13 @@ const mediaChannelGroups = computed(() => {
} }
return Array.from(groups.values()) return Array.from(groups.values())
}) })
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0)))) const rankingKind = computed(() => dashboard.meta.ranking_kind || (
const maxAmountValue = computed(() => Math.max(0, ...dashboard.rankings.amounts.map(item => Number(item.value || 0)))) Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group'
))
const showRankings = computed(() => rankingKind.value !== 'hidden')
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
const targetChartOption = computed(() => ({ const targetChartOption = computed(() => ({
animationDuration: 450, animationDuration: 450,
color: ['#0f9185', '#2f78df'], color: ['#0f9185', '#2f78df'],
@@ -447,9 +452,14 @@ function formatMoney(value: any) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
} }
function formatPercent(value: any) { function formatPercent(value: any) {
return `${Number(value || 0).toFixed(1)}%` return `${Number(value || 0).toFixed(1)}%`
} }
function formatShare(value: any, total: number) {
if (total <= 0) return '0.0%'
return `${(Number(value || 0) / total * 100).toFixed(1)}%`
}
function nullablePercent(value: any) { function nullablePercent(value: any) {
return value === null || value === undefined ? '未设置' : formatPercent(value) return value === null || value === undefined ? '未设置' : formatPercent(value)
@@ -464,10 +474,11 @@ function compactNumber(value: number) {
return String(Math.round(value)) return String(Math.round(value))
} }
function barWidth(value: any, maximum: number) { function barWidth(value: any, total: number) {
if (maximum <= 0) return '0%' const numericValue = Number(value || 0)
return `${Math.max(4, Math.min(100, Number(value || 0) / maximum * 100))}%` if (total <= 0 || numericValue <= 0) return '0%'
} return `${Math.max(4, Math.min(100, numericValue / total * 100))}%`
}
function progressValue(value: any) { function progressValue(value: any) {
return Math.max(0, Math.min(100, Number(value || 0))) return Math.max(0, Math.min(100, Number(value || 0)))
@@ -477,22 +488,29 @@ onMounted(loadDashboard)
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.conversion-page { .conversion-page {
display: grid; display: grid;
gap: 14px; gap: 14px;
min-height: 640px; width: 100%;
padding: 16px; max-width: 100%;
color: #172033; min-width: 0;
background: #f4f6f8; min-height: 640px;
} padding: 16px;
overflow-x: hidden;
box-sizing: border-box;
color: #172033;
background: #f4f6f8;
}
.page-heading, .page-heading,
.filter-strip, .filter-strip,
.panel, .panel,
.metric-card { .metric-card {
border: 1px solid #dfe5ec; min-width: 0;
background: #fff; box-sizing: border-box;
} border: 1px solid #dfe5ec;
background: #fff;
}
.page-heading { .page-heading {
display: flex; display: flex;
@@ -566,7 +584,7 @@ onMounted(loadDashboard)
} }
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } .ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel { padding: 16px; border-radius: 10px; } .panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
.panel-heading { .panel-heading {
display: flex; display: flex;
@@ -580,18 +598,26 @@ onMounted(loadDashboard)
> span { color: #929dac; font-size: 11px; white-space: nowrap; } > span { color: #929dac; font-size: 11px; white-space: nowrap; }
} }
.bar-list { display: grid; gap: 13px; } .bar-list { display: grid; gap: 13px; max-height: 340px; overflow-y: auto; padding-right: 4px; }
.bar-row { display: grid; grid-template-columns: 110px minmax(80px, 1fr) 94px; align-items: center; gap: 10px; } .bar-row { display: grid; grid-template-columns: 130px minmax(80px, 1fr) 116px; align-items: center; gap: 10px; }
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-row > strong { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; } .bar-name b { display: inline-block; width: 20px; margin-right: 7px; color: #98a2b3; font-size: 11px; font-weight: 650; text-align: center; }
.bar-row > strong { display: flex; align-items: baseline; justify-content: flex-end; gap: 7px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.bar-row > strong small { color: #8a95a5; font-size: 10px; font-weight: 500; }
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; } .bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; } .bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
.bar-track i.is-teal { background: #15998d; } .bar-track i.is-teal { background: #15998d; }
.bar-track i.is-blue { background: #307bdf; } .bar-track i.is-blue { background: #307bdf; }
.detail-panel { padding-bottom: 10px; } .detail-panel { padding-bottom: 10px; overflow: hidden; }
.detail-table { .detail-table {
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; } width: 100%;
max-width: 100%;
min-width: 0;
:deep(.el-table__inner-wrapper),
:deep(.el-scrollbar) { max-width: 100%; }
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
:deep(td.el-table__cell) { color: #273347; font-size: 12px; } :deep(td.el-table__cell) { color: #273347; font-size: 12px; }
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; } :deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
strong.is-parent { color: #172033; font-weight: 700; } strong.is-parent { color: #172033; font-weight: 700; }
@@ -632,7 +658,7 @@ onMounted(loadDashboard)
.metric-grid, .ranking-grid { grid-template-columns: 1fr; } .metric-grid, .ranking-grid { grid-template-columns: 1fr; }
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; } .filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
.employee-select, .dept-select, .channel-select { width: 100%; } .employee-select, .dept-select, .channel-select { width: 100%; }
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; } .bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
.target-summary { grid-template-columns: 1fr; } .target-summary { grid-template-columns: 1fr; }
} }
</style> </style>
@@ -98,7 +98,7 @@
</div> </div>
</article> </article>
<article class="metric-card"> <article v-if="dashboard.scope.kind !== 'assistant'" class="metric-card">
<div class="metric-label-row"> <div class="metric-label-row">
<span>本人本月业绩</span> <span>本人本月业绩</span>
<el-tooltip :content="dashboard.meta.commission_note" placement="top"> <el-tooltip :content="dashboard.meta.commission_note" placement="top">
@@ -151,11 +151,11 @@
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>{{ dashboard.rankings.appointments.title }}</h2> <h2>{{ dashboard.rankings.appointments.title }}</h2>
<p>{{ appointmentRankingSubtitle }}</p> <p v-if="appointmentRankingSubtitle">{{ appointmentRankingSubtitle }}</p>
</div> </div>
<div class="panel-header-actions"> <div class="panel-header-actions">
<el-tree-select <el-tree-select
v-if="dashboard.rankings.appointments.kind !== 'doctor'" v-if="showRankingDeptSelect"
v-model="rankingDeptId" v-model="rankingDeptId"
:data="dashboard.filters.ranking_departments" :data="dashboard.filters.ranking_departments"
:props="departmentTreeProps" :props="departmentTreeProps"
@@ -164,7 +164,7 @@
clearable clearable
filterable filterable
default-expand-all default-expand-all
placeholder="全部可见部门" placeholder="全部部门"
class="ranking-dept-select" class="ranking-dept-select"
@change="handleRankingDeptChange" @change="handleRankingDeptChange"
/> />
@@ -176,12 +176,16 @@
v-for="(item, index) in dashboard.rankings.appointments.items" v-for="(item, index) in dashboard.rankings.appointments.items"
:key="`appointment-${item.id}-${index}`" :key="`appointment-${item.id}-${index}`"
class="ranking-row" class="ranking-row"
:class="{ 'is-self': item.is_self }"
> >
<span class="ranking-index" :class="{ 'is-top': index < 2 }">{{ index + 1 }}</span> <span class="ranking-index" :class="{ 'is-top': index < 2, 'is-self': item.is_self }">{{ index + 1 }}</span>
<div class="ranking-main"> <div class="ranking-main">
<div class="ranking-copy"> <div class="ranking-copy">
<strong>{{ item.name || '未命名成员' }}</strong> <strong>
<span>{{ formatNumber(item.count) }} 挂号</span> {{ item.name || '未命名成员' }}
<i v-if="item.is_self" class="ranking-self-tag"></i>
</strong>
<span>{{ formatNumber(item.count) }} {{ rankingCountUnit }}</span>
</div> </div>
<div class="ranking-meter" aria-hidden="true"> <div class="ranking-meter" aria-hidden="true">
<span :style="{ width: rankingWidth(item.count, appointmentRankingMax) }" /> <span :style="{ width: rankingWidth(item.count, appointmentRankingMax) }" />
@@ -190,14 +194,16 @@
<b class="ranking-value">{{ formatNumber(item.count) }}</b> <b class="ranking-value">{{ formatNumber(item.count) }}</b>
</div> </div>
</div> </div>
<el-empty v-else :image-size="72" description="当前数据范围内暂无挂号排行" /> <el-empty v-else :image-size="72" :description="`当前数据范围内暂无${rankingCountUnit}排行`" />
</article> </article>
<article class="dashboard-panel ranking-panel"> <article class="dashboard-panel ranking-panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<h2>{{ dashboard.rankings.performance.title }}</h2> <h2>{{ dashboard.rankings.performance.title }}</h2>
<p>{{ dashboard.rankings.performance.scope_label }}内今日计入业绩的业务订单</p> <p>
{{ performanceRankingSubtitle }}
</p>
</div> </div>
<span class="panel-meta">今日</span> <span class="panel-meta">今日</span>
</div> </div>
@@ -206,11 +212,15 @@
v-for="(item, index) in dashboard.rankings.performance.items" v-for="(item, index) in dashboard.rankings.performance.items"
:key="`performance-${item.id}-${index}`" :key="`performance-${item.id}-${index}`"
class="ranking-row" class="ranking-row"
:class="{ 'is-self': item.is_self }"
> >
<span class="ranking-index" :class="{ 'is-top': index < 2 }">{{ index + 1 }}</span> <span class="ranking-index" :class="{ 'is-top': index < 2, 'is-self': item.is_self }">{{ index + 1 }}</span>
<div class="ranking-main"> <div class="ranking-main">
<div class="ranking-copy"> <div class="ranking-copy">
<strong>{{ item.name || '未命名部门' }}</strong> <strong>
{{ item.name || (dashboard.rankings.performance.kind === 'person' ? '未命名成员' : '未命名部门') }}
<i v-if="item.is_self" class="ranking-self-tag"></i>
</strong>
<span>{{ formatNumber(item.count) }} 个诊单</span> <span>{{ formatNumber(item.count) }} 个诊单</span>
</div> </div>
<div class="ranking-meter" aria-hidden="true"> <div class="ranking-meter" aria-hidden="true">
@@ -335,6 +345,7 @@ interface RankingItem {
name: string name: string
count: number count: number
amount: number amount: number
is_self?: boolean
} }
type ComparisonDirection = 'up' | 'down' | 'flat' type ComparisonDirection = 'up' | 'down' | 'flat'
@@ -355,10 +366,12 @@ const emptyComparison = (): MetricComparison => ({
const createInitialDashboard = () => ({ const createInitialDashboard = () => ({
scope: { scope: {
key: '', key: '',
kind: '',
scope_value: 4, scope_value: 4,
label: '', label: '',
is_limited: true, is_limited: true,
viewer_name: '', viewer_name: '',
viewer_id: 0,
role_ids: [] as number[], role_ids: [] as number[],
role_names: [] as string[], role_names: [] as string[],
department_names: [] as string[], department_names: [] as string[],
@@ -401,11 +414,13 @@ const createInitialDashboard = () => ({
appointments: { appointments: {
title: '实时挂号排行', title: '实时挂号排行',
kind: 'assistant', kind: 'assistant',
metric: 'registration',
scope_label: '', scope_label: '',
items: [] as RankingItem[], items: [] as RankingItem[],
}, },
performance: { performance: {
title: '今日部门业绩排行', title: '今日部门业绩排行',
kind: 'dept',
scope_label: '', scope_label: '',
items: [] as RankingItem[], items: [] as RankingItem[],
}, },
@@ -413,6 +428,7 @@ const createInitialDashboard = () => ({
filters: { filters: {
ranking_departments: [] as any[], ranking_departments: [] as any[],
ranking_dept_id: 0, ranking_dept_id: 0,
ranking_selectable: true,
}, },
trend: { trend: {
date_range: [] as string[], date_range: [] as string[],
@@ -533,9 +549,34 @@ const currentDate = computed(() => new Intl.DateTimeFormat('zh-CN', {
weekday: 'short', weekday: 'short',
}).format(now.value)) }).format(now.value))
const showRankingDeptSelect = computed(() => (
dashboard.filters.ranking_selectable !== false
&& dashboard.scope.kind !== 'group_leader'
&& dashboard.rankings.appointments.kind !== 'doctor'
&& dashboard.filters.ranking_departments.length > 0
))
const rankingCountUnit = computed(() => (
dashboard.rankings.appointments.metric === 'appointment' ? '预约' : '挂号'
))
const appointmentRankingSubtitle = computed(() => { const appointmentRankingSubtitle = computed(() => {
if (dashboard.rankings.appointments.metric === 'appointment') {
return ''
}
const actor = dashboard.rankings.appointments.kind === 'doctor' ? '医生' : '成员' const actor = dashboard.rankings.appointments.kind === 'doctor' ? '医生' : '成员'
return `${dashboard.rankings.appointments.scope_label}${actor}已支付且实收低于 10 元的订单数` const scopeLabel = dashboard.rankings.appointments.scope_label || '当前部门'
return `${scopeLabel}${actor}已支付且实收低于 10 元的订单数`
})
const performanceRankingSubtitle = computed(() => {
const scopeLabel = dashboard.rankings.performance.scope_label || '当前部门'
if (dashboard.rankings.performance.kind === 'person') {
return dashboard.scope.kind === 'group_leader'
? '本小组内今日计入业绩的个人订单'
: `${scopeLabel}内今日计入业绩的个人订单`
}
return `${scopeLabel}内今日计入业绩的业务订单`
}) })
const appointmentRankingMax = computed(() => Math.max( const appointmentRankingMax = computed(() => Math.max(
@@ -656,7 +697,7 @@ const loadDashboard = async () => {
errorMessage.value = '' errorMessage.value = ''
try { try {
const res: any = await performanceDashboardOverview({ const res: any = await performanceDashboardOverview({
ranking_dept_id: rankingDeptId.value, ranking_dept_id: loaded.value ? (rankingDeptId.value || 0) : undefined,
// 驾驶舱是实时数据,避免浏览器或反向代理复用旧的 GET 响应。 // 驾驶舱是实时数据,避免浏览器或反向代理复用旧的 GET 响应。
_t: Date.now(), _t: Date.now(),
}) })
@@ -998,6 +1039,11 @@ onBeforeUnmount(() => {
padding: 7px 9px; padding: 7px 9px;
border-radius: 11px; border-radius: 11px;
background: var(--dash-surface-muted); background: var(--dash-surface-muted);
&.is-self {
background: color-mix(in srgb, var(--dash-accent) 16%, var(--dash-surface));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dash-accent) 42%, transparent);
}
} }
.ranking-index { .ranking-index {
@@ -1017,6 +1063,24 @@ onBeforeUnmount(() => {
color: #fff; color: #fff;
background: var(--dash-accent); background: var(--dash-accent);
} }
&.is-self:not(.is-top) {
color: var(--dash-accent);
background: color-mix(in srgb, var(--dash-accent) 18%, var(--el-fill-color-light));
font-weight: 750;
}
}
.ranking-self-tag {
flex-shrink: 0;
padding: 0 5px;
border-radius: 6px;
color: #fff;
font-size: 10px;
font-style: normal;
font-weight: 700;
line-height: 18px;
background: var(--dash-accent);
} }
.ranking-main { .ranking-main {
@@ -1032,6 +1096,9 @@ onBeforeUnmount(() => {
margin-bottom: 5px; margin-bottom: 5px;
strong { strong {
display: inline-flex;
align-items: center;
gap: 6px;
overflow: hidden; overflow: hidden;
font-size: 12px; font-size: 12px;
text-overflow: ellipsis; text-overflow: ellipsis;
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 188 KiB

@@ -43,7 +43,7 @@ from .repository import (
ItemT = TypeVar("ItemT") ItemT = TypeVar("ItemT")
DEMO_PERMISSIONS: tuple[str, ...] = ( DEMO_PERMISSIONS: tuple[str, ...] = (
"doctor.appointment/lists", "doctor.appointment/lists",
"doctor.appointment/reception", "doctor.appointment/reception",
"doctor.appointment/notifyAssistant", "doctor.appointment/notifyAssistant",
@@ -59,12 +59,12 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
"tcm.diagnosis/delete", "tcm.diagnosis/delete",
"tcm.diagnosis/readonlyDetail", "tcm.diagnosis/readonlyDetail",
"tcm.diagnosis/aiReports", "tcm.diagnosis/aiReports",
"tcm.diagnosis/generateAiReports", "tcm.diagnosis/generateAiReports",
"tcm.diagnosis/editAiReport", "tcm.diagnosis/editAiReport",
"tcm.diagnosis/aiAnalysis", "tcm.diagnosis/aiAnalysis",
"tcm.diagnosis/aiAssistant", "tcm.diagnosis/aiAssistant",
"tcm.diagnosis/patientAiReports", "tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport", "tcm.diagnosis/generatePatientAiReport",
"tcm.diagnosis/dailyRecord", "tcm.diagnosis/dailyRecord",
"tcm.diagnosis/chufang", "tcm.diagnosis/chufang",
"tcm.diagnosis/huifang", "tcm.diagnosis/huifang",
@@ -120,12 +120,12 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
"tcm.diagnosis/guahao", "tcm.diagnosis/guahao",
"tcm.diagnosis/fillIdCard", "tcm.diagnosis/fillIdCard",
"doctor.medicine/lists", "doctor.medicine/lists",
) )
PATIENT_AI_MEDICAL_DISCLAIMER = ( PATIENT_AI_MEDICAL_DISCLAIMER = (
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。" "仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。" "系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
) )
class DemoDoctorRepository: class DemoDoctorRepository:
@@ -155,12 +155,12 @@ class DemoDoctorRepository:
self._template_ai_reports: dict[int, list[dict[str, Any]]] = ( self._template_ai_reports: dict[int, list[dict[str, Any]]] = (
self._build_template_ai_reports() self._build_template_ai_reports()
) )
self._diagnosis_ai_reports: dict[int, list[dict[str, Any]]] = ( self._diagnosis_ai_reports: dict[int, list[dict[str, Any]]] = (
self._build_diagnosis_ai_reports() self._build_diagnosis_ai_reports()
) )
self._patient_ai_reports: dict[int, list[dict[str, Any]]] = ( self._patient_ai_reports: dict[int, list[dict[str, Any]]] = (
self._build_patient_ai_reports() self._build_patient_ai_reports()
) )
self._prescriptions = self._build_prescriptions() self._prescriptions = self._build_prescriptions()
self._medicines = self._build_medicines() self._medicines = self._build_medicines()
self._patient_orders = self._build_patient_orders() self._patient_orders = self._build_patient_orders()
@@ -987,7 +987,7 @@ class DemoDoctorRepository:
consultation, generated=True, status="success" consultation, generated=True, status="success"
) )
def edit_diagnosis_ai_report( def edit_diagnosis_ai_report(
self, self,
diagnosis_id: int, diagnosis_id: int,
*, *,
@@ -1027,155 +1027,155 @@ class DemoDoctorRepository:
target["edited_by"] = 1001 target["edited_by"] = 1001
target["edited_time"] = int(now.timestamp()) target["edited_time"] = int(now.timestamp())
target["edited_at"] = now.isoformat(sep=" ") target["edited_at"] = now.isoformat(sep=" ")
return { return {
"diagnosis_id": consultation.id, "diagnosis_id": consultation.id,
"report": deepcopy(target), "report": deepcopy(target),
"can_edit": True, "can_edit": True,
"can_refresh": True, "can_refresh": True,
} }
def analyze_diagnosis_ai( def analyze_diagnosis_ai(
self, self,
diagnosis_id: int, diagnosis_id: int,
prompt: str, prompt: str,
*, *,
task: str = "custom", task: str = "custom",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return a deterministic offline answer matching the server DTO.""" """Return a deterministic offline answer matching the server DTO."""
clean_prompt = prompt.strip() clean_prompt = prompt.strip()
if not clean_prompt: if not clean_prompt:
raise ValueError("prompt is required") raise ValueError("prompt is required")
if len(clean_prompt) > 500: if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters") raise ValueError("prompt must not exceed 500 characters")
if task not in { if task not in {
"summary", "summary",
"tcm_pattern", "tcm_pattern",
"prescription_review", "prescription_review",
"medication_review", "medication_review",
"exam_review", "exam_review",
"complication_risk", "complication_risk",
"guideline_review", "guideline_review",
"custom", "custom",
}: }:
raise ValueError("task is not supported") raise ValueError("task is not supported")
with self._lock: with self._lock:
consultation = self._find_consultation(diagnosis_id) consultation = self._find_consultation(diagnosis_id)
model_key = ( model_key = (
"openai" "openai"
if task in {"exam_review", "complication_risk", "guideline_review"} if task in {"exam_review", "complication_risk", "guideline_review"}
else "qwen" else "qwen"
) )
return { return {
"diagnosis_id": consultation.id, "diagnosis_id": consultation.id,
"answer": ( "answer": (
f"已结合{consultation.patient_name}的当前病历分析“{clean_prompt}”。" f"已结合{consultation.patient_name}的当前病历分析“{clean_prompt}”。"
"请由医生结合四诊与最新检验结果复核。" "请由医生结合四诊与最新检验结果复核。"
), ),
"model_key": model_key, "model_key": model_key,
"model_label": "千问" if model_key == "qwen" else "OpenAI", "model_label": "千问" if model_key == "qwen" else "OpenAI",
"task": task, "task": task,
} }
def get_diagnosis_ai_analysis( def get_diagnosis_ai_analysis(
self, self,
diagnosis_id: int, diagnosis_id: int,
*, *,
model: Literal["qwen", "openai"] = "qwen", model: Literal["qwen", "openai"] = "qwen",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return one model's structured offline analysis for the demo case.""" """Return one model's structured offline analysis for the demo case."""
if diagnosis_id <= 0: if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive") raise ValueError("diagnosis_id must be positive")
clean_model = str(model).strip().lower() clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}: if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai") raise ValueError("model must be qwen or openai")
with self._lock: with self._lock:
consultation = self._find_consultation(diagnosis_id) consultation = self._find_consultation(diagnosis_id)
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {} raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
diagnosis = str(raw.get("clinical_diagnosis") or "待进一步辨证").strip() diagnosis = str(raw.get("clinical_diagnosis") or "待进一步辨证").strip()
complaint = str(raw.get("chief_complaint") or "主诉信息待完善").strip() complaint = str(raw.get("chief_complaint") or "主诉信息待完善").strip()
try: try:
glucose = float(raw.get("fasting_blood_sugar")) glucose = float(raw.get("fasting_blood_sugar"))
except (TypeError, ValueError): except (TypeError, ValueError):
glucose = None glucose = None
risk_assessment: list[dict[str, str]] = [] risk_assessment: list[dict[str, str]] = []
if glucose is not None and glucose >= 7.0: if glucose is not None and glucose >= 7.0:
risk_assessment.append({"label": "高血糖风险", "level": "high"}) risk_assessment.append({"label": "高血糖风险", "level": "high"})
elif glucose is not None and glucose >= 6.1: elif glucose is not None and glucose >= 6.1:
risk_assessment.append({"label": "血糖波动风险", "level": "medium"}) risk_assessment.append({"label": "血糖波动风险", "level": "medium"})
if "" in complaint or "睡眠" in str(raw.get("remark") or ""): if "" in complaint or "睡眠" in str(raw.get("remark") or ""):
risk_assessment.append({"label": "睡眠质量风险", "level": "low"}) risk_assessment.append({"label": "睡眠质量风险", "level": "low"})
if not risk_assessment: if not risk_assessment:
risk_assessment.append({"label": "需持续随访", "level": "low"}) risk_assessment.append({"label": "需持续随访", "level": "low"})
treatment = str( treatment = str(
raw.get("treatment_advice") raw.get("treatment_advice")
or raw.get("treatment_principle") or raw.get("treatment_principle")
or raw.get("prescription_advice") or raw.get("prescription_advice")
or f"围绕{diagnosis}继续完善四诊信息,并结合最新检验结果调整方案。" or f"围绕{diagnosis}继续完善四诊信息,并结合最新检验结果调整方案。"
).strip() ).strip()
diagnosis_advice = f"{diagnosis};重点复核:{complaint}" diagnosis_advice = f"{diagnosis};重点复核:{complaint}"
if clean_model == "openai": if clean_model == "openai":
diagnosis_advice = f"{diagnosis};建议同步核对主诉与客观检查:{complaint}" diagnosis_advice = f"{diagnosis};建议同步核对主诉与客观检查:{complaint}"
treatment = f"{treatment} 同时复核近期检查趋势与用药安全性。" treatment = f"{treatment} 同时复核近期检查趋势与用药安全性。"
return { return {
"diagnosis_advice": diagnosis_advice, "diagnosis_advice": diagnosis_advice,
"risk_assessment": risk_assessment, "risk_assessment": risk_assessment,
"treatment_advice": treatment, "treatment_advice": treatment,
"model_key": clean_model, "model_key": clean_model,
"model_label": "千问" if clean_model == "qwen" else "OpenAI", "model_label": "千问" if clean_model == "qwen" else "OpenAI",
"model_name": ( "model_name": (
"qwen3.6-35b-demo" if clean_model == "qwen" else "openai-demo" "qwen3.6-35b-demo" if clean_model == "qwen" else "openai-demo"
), ),
"generated_at": datetime.now().replace(microsecond=0).isoformat(sep=" "), "generated_at": datetime.now().replace(microsecond=0).isoformat(sep=" "),
} }
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]: def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
"""Return every saved patient snapshot without generating a new one.""" """Return every saved patient snapshot without generating a new one."""
if patient_id <= 0: if patient_id <= 0:
raise ValueError("patient_id must be positive") raise ValueError("patient_id must be positive")
with self._lock: with self._lock:
consultation = self._find_patient_consultation(patient_id) consultation = self._find_patient_consultation(patient_id)
return self._patient_ai_reports_payload(patient_id, consultation) return self._patient_ai_reports_payload(patient_id, consultation)
def generate_patient_ai_report( def generate_patient_ai_report(
self, self,
patient_id: int, patient_id: int,
*, *,
model: Literal["qwen", "openai"], model: Literal["qwen", "openai"],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Append one deterministic demo snapshot while retaining every version.""" """Append one deterministic demo snapshot while retaining every version."""
if patient_id <= 0: if patient_id <= 0:
raise ValueError("patient_id must be positive") raise ValueError("patient_id must be positive")
clean_model = str(model).strip().lower() clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}: if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai") raise ValueError("model must be qwen or openai")
with self._lock: with self._lock:
consultation = self._find_patient_consultation(patient_id) consultation = self._find_patient_consultation(patient_id)
current = self._patient_ai_reports.setdefault(patient_id, []) current = self._patient_ai_reports.setdefault(patient_id, [])
version = 1 + sum( version = 1 + sum(
str(row.get("model_key") or "").strip().lower() == clean_model str(row.get("model_key") or "").strip().lower() == clean_model
for row in current for row in current
) )
generated = self._make_patient_ai_report( generated = self._make_patient_ai_report(
patient_id, patient_id,
consultation, consultation,
model=clean_model, model=clean_model,
generated_at=datetime.now().replace(microsecond=0).isoformat(sep=" "), generated_at=datetime.now().replace(microsecond=0).isoformat(sep=" "),
version=version, version=version,
) )
current.append(generated) current.append(generated)
return { return {
"patient_id": patient_id, "patient_id": patient_id,
"generated_report": deepcopy(generated), "generated_report": deepcopy(generated),
"report": deepcopy(generated), "report": deepcopy(generated),
"disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER, "disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER,
"source_summary": deepcopy(generated.get("source_summary", {})), "source_summary": deepcopy(generated.get("source_summary", {})),
} }
def list_medicines( def list_medicines(
self, self,
*, *,
name: str = "", name: str = "",
@@ -3138,23 +3138,23 @@ class DemoDoctorRepository:
return patient return patient
raise RepositoryNotFoundError(f"patient diagnosis {diagnosis_id} not found") raise RepositoryNotFoundError(f"patient diagnosis {diagnosis_id} not found")
def _find_consultation(self, diagnosis_id: int) -> Consultation: def _find_consultation(self, diagnosis_id: int) -> Consultation:
for consultation in self._consultations: for consultation in self._consultations:
if consultation.id == diagnosis_id: if consultation.id == diagnosis_id:
return consultation return consultation
raise RepositoryNotFoundError(f"consultation {diagnosis_id} not found") raise RepositoryNotFoundError(f"consultation {diagnosis_id} not found")
def _find_patient_consultation(self, patient_id: int) -> Consultation: def _find_patient_consultation(self, patient_id: int) -> Consultation:
"""Return the most recent demo consultation for a source patient id.""" """Return the most recent demo consultation for a source patient id."""
matches = [ matches = [
consultation consultation
for consultation in self._consultations for consultation in self._consultations
if consultation.patient_id == patient_id if consultation.patient_id == patient_id
] ]
if matches: if matches:
return max(matches, key=lambda consultation: consultation.id) return max(matches, key=lambda consultation: consultation.id)
raise RepositoryNotFoundError(f"patient {patient_id} not found") raise RepositoryNotFoundError(f"patient {patient_id} not found")
def _find_order(self, order_id: int) -> dict[str, Any]: def _find_order(self, order_id: int) -> dict[str, Any]:
for order in self._patient_orders: for order in self._patient_orders:
@@ -3296,6 +3296,9 @@ class DemoDoctorRepository:
"period": "上午", "period": "上午",
"status": 1, "status": 1,
"status_desc": "待接诊", "status_desc": "待接诊",
"clinical_diagnosis": "肝郁脾虚证",
"disease_course_text": "病程 3 年",
"fasting_blood_sugar": 6.8,
"diagnosis_confirmed": 1, "diagnosis_confirmed": 1,
"has_prescription": 0, "has_prescription": 0,
"remark": "复诊,关注睡眠与口干。", "remark": "复诊,关注睡眠与口干。",
@@ -3320,6 +3323,9 @@ class DemoDoctorRepository:
"period": "上午", "period": "上午",
"status": 4, "status": 4,
"status_desc": "已过号", "status_desc": "已过号",
"clinical_diagnosis": "痰湿中阻证",
"disease_course_text": "病程 8 年",
"fasting_blood_sugar": 8.6,
"diagnosis_confirmed": 0, "diagnosis_confirmed": 0,
"has_prescription": 1, "has_prescription": 1,
"prescription_audit_status": 1, "prescription_audit_status": 1,
@@ -3370,6 +3376,9 @@ class DemoDoctorRepository:
"period": "上午", "period": "上午",
"status": 3, "status": 3,
"status_desc": "已完成", "status_desc": "已完成",
"clinical_diagnosis": "气阴两虚证",
"disease_course_text": "病程 5 年",
"fasting_blood_sugar": 7.2,
"diagnosis_confirmed": 1, "diagnosis_confirmed": 1,
"has_prescription": 1, "has_prescription": 1,
"prescription_audit_status": 1, "prescription_audit_status": 1,
@@ -3872,10 +3881,10 @@ class DemoDoctorRepository:
"is_edited": False, "is_edited": False,
} }
def _build_diagnosis_ai_reports(self) -> dict[int, list[dict[str, Any]]]: def _build_diagnosis_ai_reports(self) -> dict[int, list[dict[str, Any]]]:
consultation = next((row for row in self._consultations if row.id == 501), None) consultation = next((row for row in self._consultations if row.id == 501), None)
if consultation is None: if consultation is None:
return {} return {}
fingerprint = _consultation_fingerprint(consultation) fingerprint = _consultation_fingerprint(consultation)
return { return {
501: [ 501: [
@@ -3886,158 +3895,158 @@ class DemoDoctorRepository:
name="qwen3.6-35b", name="qwen3.6-35b",
fingerprint=fingerprint, fingerprint=fingerprint,
generated_at="2026-08-12 09:18:00", generated_at="2026-08-12 09:18:00",
) )
] ]
} }
def _patient_ai_reports_payload( def _patient_ai_reports_payload(
self, self,
patient_id: int, patient_id: int,
consultation: Consultation, consultation: Consultation,
) -> dict[str, Any]: ) -> dict[str, Any]:
reports = sorted( reports = sorted(
(deepcopy(row) for row in self._patient_ai_reports.get(patient_id, [])), (deepcopy(row) for row in self._patient_ai_reports.get(patient_id, [])),
key=lambda row: ( key=lambda row: (
str(row.get("generated_at") or row.get("created_at") or ""), str(row.get("generated_at") or row.get("created_at") or ""),
int(row.get("id") or 0), int(row.get("id") or 0),
), ),
reverse=True, reverse=True,
) )
latest_by_model: dict[str, dict[str, Any] | None] = { latest_by_model: dict[str, dict[str, Any] | None] = {
"qwen": None, "qwen": None,
"openai": None, "openai": None,
} }
for row in reports: for row in reports:
model = str(row.get("model_key") or "").strip().lower() model = str(row.get("model_key") or "").strip().lower()
if model in latest_by_model and latest_by_model[model] is None: if model in latest_by_model and latest_by_model[model] is None:
latest_by_model[model] = deepcopy(row) latest_by_model[model] = deepcopy(row)
source_summary = ( source_summary = (
deepcopy(reports[0].get("source_summary")) deepcopy(reports[0].get("source_summary"))
if reports and isinstance(reports[0].get("source_summary"), Mapping) if reports and isinstance(reports[0].get("source_summary"), Mapping)
else {} else {}
) )
return { return {
"patient_id": patient_id, "patient_id": patient_id,
"reports": reports, "reports": reports,
"latest_by_model": latest_by_model, "latest_by_model": latest_by_model,
"disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER, "disclaimer": PATIENT_AI_MEDICAL_DISCLAIMER,
"source_summary": source_summary, "source_summary": source_summary,
} }
def _make_patient_ai_report( def _make_patient_ai_report(
self, self,
patient_id: int, patient_id: int,
consultation: Consultation, consultation: Consultation,
*, *,
model: str, model: str,
generated_at: str, generated_at: str,
version: int, version: int,
) -> dict[str, Any]: ) -> dict[str, Any]:
report_id = self._next_ai_report_id report_id = self._next_ai_report_id
self._next_ai_report_id += 1 self._next_ai_report_id += 1
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {} raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
base_diagnosis = str( base_diagnosis = str(
raw.get("clinical_diagnosis") raw.get("clinical_diagnosis")
or consultation.clinical_diagnosis or consultation.clinical_diagnosis
or "待进一步辨证" or "待进一步辨证"
).strip() ).strip()
complaint = str( complaint = str(
raw.get("chief_complaint") raw.get("chief_complaint")
or consultation.chief_complaint or consultation.chief_complaint
or "主诉信息待完善" or "主诉信息待完善"
).strip() ).strip()
is_openai = model == "openai" is_openai = model == "openai"
diagnosis = ( diagnosis = (
f"{base_diagnosis};建议结合客观检查复核:{complaint}" f"{base_diagnosis};建议结合客观检查复核:{complaint}"
if is_openai if is_openai
else f"{base_diagnosis};重点结合四诊复核:{complaint}" else f"{base_diagnosis};重点结合四诊复核:{complaint}"
) )
risks = [ risks = [
{"label": "血糖波动风险", "level": "medium"}, {"label": "血糖波动风险", "level": "medium"},
{"label": "睡眠质量风险", "level": "low"}, {"label": "睡眠质量风险", "level": "low"},
] ]
treatment = ( treatment = (
"复核近期检查趋势、并用药物与肝肾功能,再由接诊医生决定后续方案。" "复核近期检查趋势、并用药物与肝肾功能,再由接诊医生决定后续方案。"
if is_openai if is_openai
else "继续完善四诊与血糖记录,由接诊医生结合复诊结果确定后续方案。" else "继续完善四诊与血糖记录,由接诊医生结合复诊结果确定后续方案。"
) )
disclaimer = PATIENT_AI_MEDICAL_DISCLAIMER disclaimer = PATIENT_AI_MEDICAL_DISCLAIMER
source_summary = { source_summary = {
"diagnosis_count": 1, "diagnosis_count": 1,
"doctor_note_count": 0, "doctor_note_count": 0,
"tracking_note_count": 0, "tracking_note_count": 0,
"blood_record_count": 0, "blood_record_count": 0,
"diet_record_count": 0, "diet_record_count": 0,
"exercise_record_count": 0, "exercise_record_count": 0,
"im_message_count": 0, "im_message_count": 0,
"wechat_message_count": 0, "wechat_message_count": 0,
"call_record_count": 0, "call_record_count": 0,
"transcript_segment_count": 0, "transcript_segment_count": 0,
"recording_asset_count": 0, "recording_asset_count": 0,
"source_record_count": 1, "source_record_count": 1,
"snapshot_complete": True, "snapshot_complete": True,
"may_be_truncated": False, "may_be_truncated": False,
"analysis_chunk_count": 1, "analysis_chunk_count": 1,
"analysis_reduction_rounds": 0, "analysis_reduction_rounds": 0,
"analyzed_source_bytes": 0, "analyzed_source_bytes": 0,
"analysis_complete": True, "analysis_complete": True,
} }
structured = { structured = {
"diagnosis": diagnosis, "diagnosis": diagnosis,
"risk_assessment": risks, "risk_assessment": risks,
"treatment_advice": treatment, "treatment_advice": treatment,
"disclaimer": disclaimer, "disclaimer": disclaimer,
} }
return { return {
"id": report_id, "id": report_id,
"report_id": report_id, "report_id": report_id,
"patient_id": patient_id, "patient_id": patient_id,
"diagnosis_id": consultation.id, "diagnosis_id": consultation.id,
"model_key": model, "model_key": model,
"model_name": "gpt-5.6-sol-demo" if is_openai else "qwen3.6-35b-demo", "model_name": "gpt-5.6-sol-demo" if is_openai else "qwen3.6-35b-demo",
"model_label": "OpenAI" if is_openai else "千问", "model_label": "OpenAI" if is_openai else "千问",
"version": version, "version": version,
"report": structured, "report": structured,
"content": json.dumps(structured, ensure_ascii=False), "content": json.dumps(structured, ensure_ascii=False),
"diagnosis": diagnosis, "diagnosis": diagnosis,
"diagnosis_advice": diagnosis, "diagnosis_advice": diagnosis,
"risk_assessment": deepcopy(risks), "risk_assessment": deepcopy(risks),
"treatment_advice": treatment, "treatment_advice": treatment,
"disclaimer": disclaimer, "disclaimer": disclaimer,
"source_hash": _consultation_fingerprint(consultation), "source_hash": _consultation_fingerprint(consultation),
"source_summary": source_summary, "source_summary": source_summary,
"generated_at": generated_at, "generated_at": generated_at,
"created_at": generated_at, "created_at": generated_at,
"admin_id": 1001, "admin_id": 1001,
"department_id": 10, "department_id": 10,
"department_name": "演示中医院", "department_name": "演示中医院",
} }
def _build_patient_ai_reports(self) -> dict[int, list[dict[str, Any]]]: def _build_patient_ai_reports(self) -> dict[int, list[dict[str, Any]]]:
consultation = next( consultation = next(
(row for row in self._consultations if row.patient_id == 301), (row for row in self._consultations if row.patient_id == 301),
None, None,
) )
if consultation is None: if consultation is None:
return {} return {}
reports: list[dict[str, Any]] = [] reports: list[dict[str, Any]] = []
for version, generated_at in ( for version, generated_at in (
(1, "2026-08-12 09:18:00"), (1, "2026-08-12 09:18:00"),
(2, "2026-08-13 15:42:00"), (2, "2026-08-13 15:42:00"),
): ):
for model in ("qwen", "openai"): for model in ("qwen", "openai"):
reports.append( reports.append(
self._make_patient_ai_report( self._make_patient_ai_report(
301, 301,
consultation, consultation,
model=model, model=model,
generated_at=generated_at, generated_at=generated_at,
version=version, version=version,
) )
) )
return {301: reports} return {301: reports}
def _build_templates(self) -> list[PrescriptionTemplate]: def _build_templates(self) -> list[PrescriptionTemplate]:
return [ return [
PrescriptionTemplate.from_dict( PrescriptionTemplate.from_dict(
{ {
@@ -648,7 +648,7 @@ class AppointmentsPage(QWidget):
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(22, 7, 6, 8) root.setContentsMargins(22, 7, 6, 8)
root.setSpacing(4) root.setSpacing(4)
header = PageHeader("患者列表") header = PageHeader("问诊列表")
header.title_label.hide() header.title_label.hide()
header.subtitle_label.hide() header.subtitle_label.hide()
root.addWidget(header) root.addWidget(header)
File diff suppressed because it is too large Load Diff
+22 -30
View File
@@ -86,7 +86,7 @@ class NavigationItem:
NAVIGATION = ( NAVIGATION = (
NavigationItem( NavigationItem(
"appointments", "appointments",
"接诊台", "问诊列表",
"", "",
AppointmentsPage, AppointmentsPage,
("doctor.appointment/lists",), ("doctor.appointment/lists",),
@@ -301,8 +301,8 @@ def _resolve_navigation(
continue continue
if not _canonical_allowed(permissions, item.permissions[0]): if not _canonical_allowed(permissions, item.permissions[0]):
continue continue
if item.key in {"appointments", "patients"}: if item.key in {"appointments", "reception", "patients"}:
# Keep the two product-facing navigation titles stable even when # Keep the product-facing navigation titles stable even when
# the server still carries an older menu label. # the server still carries an older menu label.
title = item.title title = item.title
else: else:
@@ -549,7 +549,7 @@ def _painted_navigation_icon(kind: str, size: int = 18) -> QIcon:
class _ShellBrandMark(QWidget): class _ShellBrandMark(QWidget):
"""Paint the supplied indigo pulse mark without a font glyph dependency.""" """Paint the supplied indigo M mark without a font-glyph asset."""
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
del event del event
@@ -562,26 +562,12 @@ class _ShellBrandMark(QWidget):
painter.setPen(QPen(QColor(117, 130, 255, 150), 1.0)) painter.setPen(QPen(QColor(117, 130, 255, 150), 1.0))
painter.setBrush(gradient) painter.setBrush(gradient)
painter.drawRoundedRect(rect, 10, 10) painter.drawRoundedRect(rect, 10, 10)
painter.setPen(QColor("#FFFFFF"))
pen = QPen(QColor("#FFFFFF"), 1.8) font = QFont(painter.font())
pen.setCapStyle(Qt.PenCapStyle.RoundCap) font.setPixelSize(18)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) font.setWeight(QFont.Weight.Bold)
painter.setPen(pen) painter.setFont(font)
painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "M")
y = self.height() / 2
painter.drawPolyline(
QPolygonF(
[
QPointF(8, y + 2),
QPointF(11, y + 2),
QPointF(14, y - 6),
QPointF(18, y + 7),
QPointF(22, y - 4),
QPointF(25, y + 2),
QPointF(self.width() - 8, y + 2),
]
)
)
class _AssistantRobot(QWidget): class _AssistantRobot(QWidget):
@@ -837,7 +823,7 @@ class ShellWindow(QMainWindow):
parent: QWidget | None = None, parent: QWidget | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("甄养堂 · AI 问诊助手") self.setWindowTitle("甄养堂 · 问诊中心")
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True) self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True) self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
self.repository = repository self.repository = repository
@@ -1032,7 +1018,7 @@ class ShellWindow(QMainWindow):
brand_copy_layout = QVBoxLayout(self.brand_copy) brand_copy_layout = QVBoxLayout(self.brand_copy)
brand_copy_layout.setContentsMargins(0, 0, 0, 0) brand_copy_layout.setContentsMargins(0, 0, 0, 0)
brand_copy_layout.setSpacing(1) brand_copy_layout.setSpacing(1)
self.brand_name = QLabel("AI问诊助手", self.brand_copy) self.brand_name = QLabel("问诊中心", self.brand_copy)
self.brand_name.setObjectName("ShellBrandName") self.brand_name.setObjectName("ShellBrandName")
brand_copy_layout.addWidget(self.brand_name) brand_copy_layout.addWidget(self.brand_name)
self.brand_subtitle = QLabel("糖尿病专科版", self.brand_copy) self.brand_subtitle = QLabel("糖尿病专科版", self.brand_copy)
@@ -1083,7 +1069,7 @@ class ShellWindow(QMainWindow):
outer_assistant.addWidget(self.assistant_card) outer_assistant.addWidget(self.assistant_card)
layout.addLayout(outer_assistant) layout.addLayout(outer_assistant)
self.model_label = QLabel("模型:服务端自动匹配 ", sidebar) self.model_label = QLabel("模型:GPT-4o 医疗版 ", sidebar)
self.model_label.setObjectName("ShellModelLabel") self.model_label.setObjectName("ShellModelLabel")
self.model_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.model_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.model_label.setFixedHeight(76) self.model_label.setFixedHeight(76)
@@ -1130,11 +1116,15 @@ class ShellWindow(QMainWindow):
font-size: 10px; font-size: 10px;
} }
QPushButton#ShellAiEntry { QPushButton#ShellAiEntry {
min-height: 34px; min-width: 38px;
padding: 0 10px; max-width: 38px;
min-height: 38px;
max-height: 38px;
padding: 0;
color: #5265F6; color: #5265F6;
background-color: transparent; background-color: transparent;
border: 0; border: 0;
border-radius: 9px;
font-weight: 700; font-weight: 700;
} }
QPushButton#ShellAiEntry:hover { background-color: #EEF1FF; } QPushButton#ShellAiEntry:hover { background-color: #EEF1FF; }
@@ -1220,11 +1210,13 @@ class ShellWindow(QMainWindow):
self.context_label.hide() self.context_label.hide()
layout.addStretch(1) layout.addStretch(1)
self.ai_top_button = QPushButton("AI 助手", topbar) self.ai_top_button = QPushButton("", topbar)
self.ai_top_button.setObjectName("ShellAiEntry") self.ai_top_button.setObjectName("ShellAiEntry")
self.ai_top_button.setIcon(_painted_shell_icon("ai", 18)) self.ai_top_button.setIcon(_painted_shell_icon("ai", 18))
self.ai_top_button.setIconSize(QSize(18, 18)) self.ai_top_button.setIconSize(QSize(18, 18))
self.ai_top_button.setCursor(Qt.CursorShape.PointingHandCursor) self.ai_top_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.ai_top_button.setToolTip("AI 助手")
self.ai_top_button.setAccessibleName("AI 助手")
self.ai_top_button.clicked.connect(self._open_ai_assistant) self.ai_top_button.clicked.connect(self._open_ai_assistant)
layout.addWidget(self.ai_top_button) layout.addWidget(self.ai_top_button)
@@ -191,6 +191,7 @@ def test_saved_history_is_rendered_without_automatic_generation(
assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [2, 2] assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [2, 2]
assert "第 2 版" in page.ai_analysis_snapshot_meta.text() assert "第 2 版" in page.ai_analysis_snapshot_meta.text()
assert page.ai_analysis_disclaimer.text() == AI_MEDICAL_DISCLAIMER assert page.ai_analysis_disclaimer.text() == AI_MEDICAL_DISCLAIMER
assert not page.ai_analysis_disclaimer.isVisibleTo(page)
assert page.ai_analysis_history_button.objectName() == "ReceptionAiHistoryButton" assert page.ai_analysis_history_button.objectName() == "ReceptionAiHistoryButton"
assert page.ai_analysis_regenerate_button.objectName() == "ReceptionAiRegenerateButton" assert page.ai_analysis_regenerate_button.objectName() == "ReceptionAiRegenerateButton"
+195 -6
View File
@@ -10,7 +10,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx import httpx
import pytest import pytest
from PySide6.QtCore import QPoint, Qt from PySide6.QtCore import QDate, QPoint, Qt
from PySide6.QtGui import QPalette from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea
@@ -129,7 +129,7 @@ def test_queue_status_badge_is_not_clipped_in_narrow_panel(
{ {
"patient_name": "张蒙", "patient_name": "张蒙",
"status": 1, "status": 1,
"status_desc": "待接诊", "status_desc": "问诊中",
"appointment_time": "12:45:00", "appointment_time": "12:45:00",
"gender": 1, "gender": 1,
"age": 36, "age": 36,
@@ -296,6 +296,33 @@ def test_queue_uses_admin_same_day_contract(
application.processEvents() application.processEvents()
def test_queue_date_picker_filters_the_selected_day(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
page = ReceptionPage(Repository(), PermissionSet([]))
page.show()
application.processEvents()
page.queue_date_button.click()
calendar = page.queue_date_button.calendarWidget()
assert calendar.isVisible()
page.queue_date_button._pick_date(QDate(2026, 8, 1))
assert not calendar.isVisible()
assert page.queue_date_button.text() == "2026-08-01"
assert page._queue_date == "2026-08-01"
assert calls[-1]["start_date"] == "2026-08-01"
assert calls[-1]["end_date"] == "2026-08-01"
page.close()
application.processEvents()
def test_silent_queue_polls_reuse_rows_and_do_not_restart_detail_or_ai( def test_silent_queue_polls_reuse_rows_and_do_not_restart_detail_or_ai(
application: QApplication, application: QApplication,
immediate_async: None, immediate_async: None,
@@ -630,14 +657,164 @@ def test_queue_load_more_accumulates_to_total_boundary(
page = ReceptionPage(Repository(), PermissionSet([])) page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh() page.refresh()
assert page.queue_list.count() == 15 assert page.queue_list.count() == 15
assert page.load_more_button.isVisibleTo(page) assert page._queue_has_more()
assert not hasattr(page, "load_more_button")
assert not hasattr(page, "add_patient_button")
page._load_more() page._load_more()
assert [call["page_no"] for call in calls] == [1, 2] assert [call["page_no"] for call in calls] == [1, 2]
assert all(call["page_size"] == 15 for call in calls) assert all(call["page_size"] == 15 for call in calls)
assert page.queue_list.count() == 22 assert page.queue_list.count() == 22
assert page.queue_summary.text() == "已加载 22 / 共 22 位患者" assert page.queue_summary.text() == "已加载 22 / 共 22 位患者"
assert page.load_more_button.isHidden() assert not page._queue_has_more()
page.close()
application.processEvents()
def test_silent_poll_keeps_already_loaded_queue_pages(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
all_rows = [
{
"id": index,
"patient_id": 1000 + index,
"diagnosis_id": 2000 + index,
"patient_name": f"患者{index:02d}",
"status": 1,
}
for index in range(1, 28)
]
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(dict(kwargs))
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {
"lists": all_rows[start : start + kwargs["page_size"]],
"count": len(all_rows),
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
row = all_rows[appointment_id - 1]
return {
"appointment": row,
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
page._load_more()
assert page.queue_list.count() == 27
assert page._queue_page == 2
calls.clear()
page.refresh(silent=True)
assert page.queue_list.count() == 27
assert page._queue_page == 2
assert calls[-1]["page_no"] == 1
assert calls[-1]["page_size"] >= 27
page.close()
application.processEvents()
def test_queue_shows_loading_indicator_while_page_request_is_open(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
class Repository:
def list_appointments(self, **_kwargs: Any) -> dict[str, Any]:
return {
"lists": [
{"id": 1, "patient_name": "加载患者", "status": 1},
],
"count": 16,
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return {
"appointment": {
"id": appointment_id,
"patient_id": 1001,
"status": 1,
},
"diagnosis": {"id": 2001, "patient_id": 1001},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
assert page.queue_loading_indicator.isVisibleTo(page)
assert page.queue_loading_indicator.label.text() == "正在加载…"
assert page.queue_loading_indicator.spinner._timer.isActive()
queued_async[0]["on_success"](
{
"lists": [{"id": 1, "patient_name": "加载患者", "status": 1}],
"count": 16,
}
)
queued_async[0]["on_finished"]()
assert not page.queue_loading_indicator.isVisibleTo(page)
assert not page.queue_loading_indicator.spinner._timer.isActive()
page._load_more()
assert page.queue_loading_indicator.isVisibleTo(page)
load_more_job = queued_async[-1]
load_more_job["on_success"](
{
"lists": [{"id": 2, "patient_name": "第二页患者", "status": 1}],
"count": 16,
}
)
load_more_job["on_finished"]()
assert not page.queue_loading_indicator.isVisibleTo(page)
page.close()
application.processEvents()
def test_queue_scrolls_to_bottom_loads_next_page(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
all_rows = [
{
"id": index,
"patient_id": 1000 + index,
"diagnosis_id": 2000 + index,
"patient_name": f"患者{index:02d}",
"status": 1,
}
for index in range(1, 23)
]
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {
"lists": all_rows[start : start + kwargs["page_size"]],
"count": len(all_rows),
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
row = all_rows[appointment_id - 1]
return {
"appointment": row,
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.resize(1280, 640)
page.show()
application.processEvents()
assert page.queue_list.count() == 15
scrollbar = page.queue_list.verticalScrollBar()
assert scrollbar.maximum() > 0
scrollbar.setValue(scrollbar.maximum())
application.processEvents()
assert [call["page_no"] for call in calls] == [1, 2]
assert page.queue_list.count() == 22
page.close() page.close()
application.processEvents() application.processEvents()
@@ -871,6 +1048,8 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
] == [("千问", "qwen"), ("OpenAI", "openai")] ] == [("千问", "qwen"), ("OpenAI", "openai")]
assert page.ai_analysis_model_selector.currentData() == "qwen" assert page.ai_analysis_model_selector.currentData() == "qwen"
assert page._ai_analysis_state == "success" assert page._ai_analysis_state == "success"
assert page.ai_analysis_stack.currentWidget() is page.ai_analysis_content_page
assert page.ai_summary_label.isVisible()
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳" assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
assert page.ai_treatment_label.text().startswith("建议调整降糖方案") assert page.ai_treatment_label.text().startswith("建议调整降糖方案")
assert page.ai_summary_label.textFormat() == Qt.TextFormat.PlainText assert page.ai_summary_label.textFormat() == Qt.TextFormat.PlainText
@@ -887,7 +1066,7 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
assert [button.text() for button in page.ai_prompt_buttons] == [ assert [button.text() for button in page.ai_prompt_buttons] == [
"基于当前病史,下一步检查建议?", "基于当前病史,下一步检查建议?",
"该患者的用药调整建议?", "该患者的用药调整建议?",
"糖尿病教育要点?", "糖尿病教育要点有哪些",
"并发症筛查建议?", "并发症筛查建议?",
] ]
assert page.ai_send_button.text() == "" assert page.ai_send_button.text() == ""
@@ -915,10 +1094,12 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
left = page.ai_analysis_card.geometry() left = page.ai_analysis_card.geometry()
right = page.ai_assistant_card.geometry() right = page.ai_assistant_card.geometry()
assert 235 <= left.height() <= 260 assert 470 <= left.height() <= 520
assert left.height() == right.height() assert left.height() == right.height()
assert 0 <= right.left() - left.right() - 1 <= 2 assert 0 <= right.left() - left.right() - 1 <= 2
assert abs(left.width() * 5 - right.width() * 4) <= 10 assert abs(left.width() * 5 - right.width() * 4) <= 10
assert page.ai_summary_label.width() <= page.ai_analysis_card.contentsRect().width()
assert page.ai_summary_label.minimumSizeHint().width() <= 48
calls_before_switch = list(analysis_calls) calls_before_switch = list(analysis_calls)
page.ai_analysis_model_selector.setCurrentIndex( page.ai_analysis_model_selector.setCurrentIndex(
@@ -931,6 +1112,14 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
) )
assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳" assert page.ai_summary_label.text() == "2 型糖尿病,血糖控制不佳"
assert analysis_calls == calls_before_switch assert analysis_calls == calls_before_switch
assert page.detail_scroll.objectName() == "ReceptionDetailScroll"
assert (
page.detail_scroll.verticalScrollBarPolicy()
== Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
page.resize(1494, 620)
application.processEvents()
assert page.detail_scroll.verticalScrollBar().maximum() > 0
page.close() page.close()
application.processEvents() application.processEvents()
+3 -3
View File
@@ -73,7 +73,7 @@ def test_appointments_navigation_is_named_reception_and_always_first() -> None:
) )
assert [(item.key, title) for item, title in resolved] == [ assert [(item.key, title) for item, title in resolved] == [
("appointments", "接诊台"), ("appointments", "问诊列表"),
("patients", "我的患者"), ("patients", "我的患者"),
] ]
@@ -86,7 +86,7 @@ def shell_window(
navigation = [ navigation = [
NavigationItem(key, title, glyph, _ShellPageDouble, (permission,)) NavigationItem(key, title, glyph, _ShellPageDouble, (permission,))
for key, title, glyph, permission in ( for key, title, glyph, permission in (
("appointments", "接诊台", "", "doctor.appointment/lists"), ("appointments", "问诊列表", "", "doctor.appointment/lists"),
("reception", "接诊台", "", "doctor.appointment/lists"), ("reception", "接诊台", "", "doctor.appointment/lists"),
( (
"prescription_library", "prescription_library",
@@ -164,7 +164,7 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
) )
assert shell_window.assistant_card.isVisible() assert shell_window.assistant_card.isVisible()
assert shell_window.assistant_button.text() == "开始对话" assert shell_window.assistant_button.text() == "开始对话"
assert "服务端自动匹配" in shell_window.model_label.text() assert "GPT-4o 医疗版" in shell_window.model_label.text()
assert shell_window.minimize_button.text() == "" assert shell_window.minimize_button.text() == ""
assert shell_window.close_button.text() == "" assert shell_window.close_button.text() == ""
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+40 -8
View File
@@ -164,10 +164,8 @@ class AdminLogic extends BaseLogic
// 禁用或更换角色后.设置token过期 // 禁用或更换角色后.设置token过期
$roleId = AdminRole::where('admin_id', $params['id'])->column('role_id'); $roleId = AdminRole::where('admin_id', $params['id'])->column('role_id');
$editRole = false; $submittedRoleIds = self::normalizeRoleIds($params['role_id'] ?? []);
if (!empty(array_diff_assoc($roleId, $params['role_id']))) { $editRole = self::roleIdsChanged($roleId, $submittedRoleIds);
$editRole = true;
}
if ($params['disable'] == 1 || $editRole) { if ($params['disable'] == 1 || $editRole) {
$tokenArr = AdminSession::where('admin_id', $params['id'])->select()->toArray(); $tokenArr = AdminSession::where('admin_id', $params['id'])->select()->toArray();
@@ -177,14 +175,13 @@ class AdminLogic extends BaseLogic
} }
Admin::update($data); Admin::update($data);
(new AdminAuthCache($params['id']))->clearAuthCache();
// 删除旧的关联信息 // 删除旧的关联信息
AdminRole::delByUserId($params['id']); AdminRole::delByUserId($params['id']);
AdminDept::delByUserId($params['id']); AdminDept::delByUserId($params['id']);
AdminJobs::delByUserId($params['id']); AdminJobs::delByUserId($params['id']);
// 角色 // 角色
self::insertRole($params['id'], $params['role_id']); self::insertRole($params['id'], $submittedRoleIds);
// 部门 // 部门
self::insertDept($params['id'], $params['dept_id'] ?? []); self::insertDept($params['id'], $params['dept_id'] ?? []);
// 岗位 // 岗位
@@ -194,6 +191,13 @@ class AdminLogic extends BaseLogic
self::importDoctorAccountToIm($params['id'], $params['name']); self::importDoctorAccountToIm($params['id'], $params['name']);
Db::commit(); Db::commit();
// 必须在角色关联提交后删除该账号的 URI 权限缓存,避免并发请求重新写入旧权限。
try {
(new AdminAuthCache($params['id']))->clearAuthCache();
} catch (\Throwable $cacheError) {
// 数据已经提交,缓存清理失败不能把成功的编辑伪装成失败;记录后等待缓存自然过期。
Log::warning('管理员角色权限缓存清理失败:admin_id=' . $params['id'] . 'error=' . $cacheError->getMessage());
}
return true; return true;
} catch (\Exception $e) { } catch (\Exception $e) {
Db::rollback(); Db::rollback();
@@ -363,7 +367,8 @@ class AdminLogic extends BaseLogic
*/ */
public static function insertRole($adminId, $roleIds) public static function insertRole($adminId, $roleIds)
{ {
if (!empty($roleIds)) { $roleIds = self::normalizeRoleIds($roleIds);
if ($roleIds !== []) {
// 角色 // 角色
$roleData = []; $roleData = [];
foreach ($roleIds as $roleId) { foreach ($roleIds as $roleId) {
@@ -376,6 +381,33 @@ class AdminLogic extends BaseLogic
} }
} }
/** @return int[] */
private static function normalizeRoleIds(mixed $roleIds): array
{
if (!is_array($roleIds)) {
return [];
}
$normalized = array_values(array_unique(array_filter(
array_map('intval', $roleIds),
static fn (int $roleId): bool => $roleId > 0
)));
sort($normalized, SORT_NUMERIC);
return $normalized;
}
/**
* 角色是无序集合;新增、移除或替换都必须使现有 Token 失效,仅顺序变化不算修改。
*
* @param array<int|string,mixed> $currentRoleIds
* @param array<int|string,mixed> $submittedRoleIds
*/
private static function roleIdsChanged(array $currentRoleIds, array $submittedRoleIds): bool
{
return self::normalizeRoleIds($currentRoleIds) !== self::normalizeRoleIds($submittedRoleIds);
}
/** /**
* @notes 新增部门 * @notes 新增部门
@@ -453,4 +485,4 @@ class AdminLogic extends BaseLogic
} }
} }
} }
@@ -64,10 +64,11 @@ class FirstVisitConversionLogic
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds); $effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
} }
if ($selectedAssistantId > 0) { $selectedAssistantValid = $selectedAssistantId <= 0;
$assistantValid = self::isActiveAssistant($selectedAssistantId) if ($selectedAssistantId > 0) {
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true)); $selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
$effectiveAdminIds = $assistantValid ? [$selectedAssistantId] : []; && ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
} }
$costAllocationAdminIds = self::costAllocationAdminIds( $costAllocationAdminIds = self::costAllocationAdminIds(
$effectiveAdminIds, $effectiveAdminIds,
@@ -136,19 +137,20 @@ class FirstVisitConversionLogic
(int) $summary['total_open_count'] (int) $summary['total_open_count']
); );
$rankingRows = self::rankingRows($rows); $rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
$rankingRows = self::rankingRows($rows, $rankingKind);
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。 // 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) $targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
? [] ? []
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId); : self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds); $target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
$selectedDeptName = $selectedDeptId > 0 $selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '') ? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: ''; : '';
$selectedAssistantName = $selectedAssistantId > 0 $selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '') ? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: ''; : '';
$selectedMediaChannelName = $selectedMediaChannelCode !== '' $selectedMediaChannelName = $selectedMediaChannelCode !== ''
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode) ? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
: ''; : '';
@@ -159,8 +161,9 @@ class FirstVisitConversionLogic
'start_date' => $startDate, 'start_date' => $startDate,
'end_date' => $endDate, 'end_date' => $endDate,
'generated_at' => date('Y-m-d H:i:s'), 'generated_at' => date('Y-m-d H:i:s'),
'scope_value' => $scopeValue, 'scope_value' => $scopeValue,
'scope_label' => DataScopeService::scopeLabel($scopeValue), 'scope_label' => DataScopeService::scopeLabel($scopeValue),
'ranking_kind' => $rankingKind,
'selected_dept_name' => $selectedDeptName, 'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName, 'selected_assistant_name' => $selectedAssistantName,
'selected_media_channel_code' => $selectedMediaChannelCode, 'selected_media_channel_code' => $selectedMediaChannelCode,
@@ -529,10 +532,33 @@ class FirstVisitConversionLogic
), static fn (string $value): bool => $value !== ''))); ), static fn (string $value): bool => $value !== '')));
} }
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */ /** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
private static function rankingRows(array $rows): array private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
{ {
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量 if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
return 'hidden';
}
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows, string $rankingKind): array
{
if ($rankingKind === 'hidden') {
return [];
}
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
// 直属下级,避免父子汇总同时参与占比。
if ($rankingKind === 'member') {
$members = [];
self::collectRankingMembers($rows, $members);
return array_values($members);
}
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。 // 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
$visibleRows = array_values(array_filter($rows, static function (array $row): bool { $visibleRows = array_values(array_filter($rows, static function (array $row): bool {
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false)); return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
@@ -557,23 +583,59 @@ class FirstVisitConversionLogic
$chartRows[] = $row; $chartRows[] = $row;
} }
return $chartRows; return $chartRows;
} }
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,array<string,mixed>> $members
*/
private static function collectRankingMembers(array $rows, array &$members): void
{
foreach ($rows as $row) {
if ((string) ($row['type'] ?? '') === 'member') {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId > 0) {
$members[$adminId] = $row;
}
continue;
}
self::collectRankingMembers(
is_array($row['children'] ?? null) ? $row['children'] : [],
$members
);
}
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */ /** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function topRows(array $rows, string $metric): array private static function topRows(array $rows, string $metric): array
{ {
$rows = array_values(array_filter($rows, static fn (array $row): bool => (int) ($row['id'] ?? 0) > 0)); $rows = array_values(array_filter($rows, static function (array $row): bool {
usort($rows, static function (array $left, array $right) use ($metric): int { if ((string) ($row['type'] ?? '') === 'member') {
return (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0); return (int) ($row['admin_id'] ?? 0) > 0;
}); }
return array_map(static fn (array $row): array => [ return (int) ($row['id'] ?? 0) > 0;
'id' => (int) ($row['id'] ?? 0), }));
'name' => (string) ($row['name'] ?? ''), usort($rows, static function (array $left, array $right) use ($metric): int {
'value' => round((float) ($row[$metric] ?? 0), 2), $valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
], array_slice($rows, 0, 6)); if ($valueCompare !== 0) {
} return $valueCompare;
}
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
if ($nameCompare !== 0) {
return $nameCompare;
}
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
});
return array_map(static fn (array $row): array => [
'id' => $row['id'] ?? 0,
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], $rows);
}
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */ /** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,407 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\auth\AdminRole;
use app\common\model\auth\SystemRole;
use app\common\model\dept\Dept;
use app\common\service\DataScope\DataScopeService;
/**
* 首页 KPI 数据范围:按角色而不是单纯按 data_scope。
*
* 医助=仅本人;组长=本小组全部成员;经理=本部门及下级;管理员/超管=全部。
* 本人业绩卡片始终按登录账号单独统计,不走这套范围。
*/
class PerformanceDashboardScope
{
public const KIND_ADMIN = 'admin';
public const KIND_MANAGER = 'manager';
public const KIND_GROUP_LEADER = 'group_leader';
public const KIND_ASSISTANT = 'assistant';
/**
* @return array{
* kind: string,
* label: string,
* metric_admin_ids: array<int>|null
* }
*/
public static function resolve(int $adminId, array $adminInfo): array
{
$roleNames = self::roleNames($adminId);
$kind = self::classify((int) ($adminInfo['root'] ?? 0) === 1, $roleNames, $adminInfo);
$metricAdminIds = self::metricAdminIds($kind, $adminId, $adminInfo);
return [
'kind' => $kind,
'label' => self::kindLabel($kind),
'metric_admin_ids' => $metricAdminIds,
];
}
public static function kindLabel(string $kind): string
{
return [
self::KIND_ADMIN => '全部数据',
self::KIND_MANAGER => '本部门',
self::KIND_GROUP_LEADER => '本小组',
self::KIND_ASSISTANT => '仅本人',
][$kind] ?? '数据范围';
}
/**
* @param string[] $roleNames
*/
public static function classify(bool $isRoot, array $roleNames, array $adminInfo = []): string
{
if ($isRoot) {
return self::KIND_ADMIN;
}
if (self::roleNamesMatch($roleNames, ['管理员'])) {
return self::KIND_ADMIN;
}
if (self::roleNamesMatch($roleNames, ['经理'])) {
return self::KIND_MANAGER;
}
if (self::roleNamesMatch($roleNames, ['诊室组长', '组长'])) {
return self::KIND_GROUP_LEADER;
}
// 医助角色固定仅本人,不因部门负责人或 data_scope 放大到小组。
if (self::roleNamesMatch($roleNames, ['医助'])) {
return self::KIND_ASSISTANT;
}
$scope = DataScopeService::getEffectiveScope($adminInfo);
return match ($scope) {
DataScopeService::SCOPE_ALL => self::KIND_ADMIN,
DataScopeService::SCOPE_DEPT_AND_CHILD => self::KIND_MANAGER,
DataScopeService::SCOPE_DEPT => self::KIND_GROUP_LEADER,
default => self::KIND_ASSISTANT,
};
}
/**
* @return array<int>|null
*/
private static function metricAdminIds(string $kind, int $adminId, array $adminInfo = []): ?array
{
if ($kind === self::KIND_ADMIN) {
return null;
}
if ($kind === self::KIND_ASSISTANT || $adminId <= 0) {
return $adminId > 0 ? [$adminId] : [];
}
if ($kind === self::KIND_GROUP_LEADER) {
$ids = self::adminsInGroup($adminId, $adminInfo);
return $ids !== [] ? $ids : ($adminId > 0 ? [$adminId] : []);
}
$ids = self::adminsInOwnDeptTree($adminId);
if ($ids === []) {
return $adminId > 0 ? [$adminId] : [];
}
return $ids;
}
/**
* 组长小组:只取本人最深的部门(不含一中心/二中心整棵树),并并入其担任负责人的部门。
*
* @return int[]
*/
private static function adminsInGroup(int $adminId, array $adminInfo): array
{
$ownDeptIds = self::ownDeptIds($adminId);
$leafDeptIds = self::leafDeptIds($ownDeptIds);
$ledDeptIds = self::ledDeptIds($adminId, $adminInfo, $ownDeptIds);
$groupDeptIds = array_values(array_unique(array_merge($leafDeptIds, $ledDeptIds)));
$groupDeptIds = self::dropCenterRootsIfHasDeeper($groupDeptIds);
if ($groupDeptIds === []) {
$groupDeptIds = $leafDeptIds !== [] ? $leafDeptIds : $ownDeptIds;
}
$deptIds = [];
foreach ($groupDeptIds as $deptId) {
foreach (DeptLogic::getSelfAndDescendantIds((int) $deptId) as $id) {
$id = (int) $id;
if ($id > 0) {
$deptIds[] = $id;
}
}
}
$deptIds = array_values(array_unique($deptIds));
if ($deptIds === []) {
return $adminId > 0 ? [$adminId] : [];
}
$adminIds = array_values(array_unique(array_filter(
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
static fn (int $id): bool => $id > 0
)));
if ($adminId > 0 && !in_array($adminId, $adminIds, true)) {
$adminIds[] = $adminId;
}
return $adminIds;
}
/**
* @return int[]
*/
private static function ownDeptIds(int $adminId): array
{
return array_values(array_unique(array_filter(
array_map('intval', AdminDept::where('admin_id', $adminId)->column('dept_id')),
static fn (int $id): bool => $id > 0
)));
}
/**
* 在本人所属部门里只留最深的节点,避免挂在「一中心」上就把整个中心当成小组。
*
* @param int[] $ownDeptIds
* @return int[]
*/
private static function leafDeptIds(array $ownDeptIds): array
{
if ($ownDeptIds === []) {
return [];
}
$deptById = [];
$rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'name'])->select()->toArray();
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id > 0) {
$deptById[$id] = [
'pid' => (int) ($row['pid'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
];
}
}
$ownSet = array_fill_keys($ownDeptIds, true);
$leaves = [];
foreach ($ownDeptIds as $id) {
$hasOwnDescendant = false;
foreach ($ownDeptIds as $other) {
if ($other === $id) {
continue;
}
if (self::isAncestorOf($id, $other, $deptById)) {
$hasOwnDescendant = true;
break;
}
}
if (!$hasOwnDescendant && isset($ownSet[$id])) {
$leaves[] = $id;
}
}
return array_values(array_unique($leaves));
}
/**
* @param array<int, array{pid: int, name: string}> $deptById
*/
private static function isAncestorOf(int $ancestorId, int $nodeId, array $deptById): bool
{
$current = $nodeId;
$seen = [];
while ($current > 0 && isset($deptById[$current]) && !isset($seen[$current])) {
$seen[$current] = true;
$pid = $deptById[$current]['pid'];
if ($pid === $ancestorId) {
return true;
}
$current = $pid;
}
return false;
}
/**
* 部门负责人姓名匹配当前组长时,把该部门算进小组。仅用于已判定为组长的账号。
*
* @param int[] $ownDeptIds
* @return int[]
*/
private static function ledDeptIds(int $adminId, array $adminInfo, array $ownDeptIds): array
{
$name = self::normalizePersonName((string) ($adminInfo['name'] ?? ''));
if ($adminId <= 0 || $name === '') {
return [];
}
$rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'leader'])->select()->toArray();
$ownSet = array_fill_keys($ownDeptIds, true);
$led = [];
foreach ($rows as $row) {
$deptId = (int) ($row['id'] ?? 0);
$leaderName = self::normalizePersonName((string) ($row['leader'] ?? ''));
if ($deptId <= 0 || $leaderName === '' || $leaderName !== $name) {
continue;
}
if ($ownSet === [] || isset($ownSet[$deptId]) || self::deptUnderOwnTree($deptId, $ownDeptIds)) {
$led[] = $deptId;
}
}
return array_values(array_unique($led));
}
/**
* @param int[] $ownDeptIds
*/
private static function deptUnderOwnTree(int $deptId, array $ownDeptIds): bool
{
foreach ($ownDeptIds as $rootId) {
$ids = DeptLogic::getSelfAndDescendantIds((int) $rootId);
foreach ($ids as $id) {
if ((int) $id === $deptId) {
return true;
}
}
}
return false;
}
/**
* @param int[] $deptIds
* @return int[]
*/
private static function dropCenterRootsIfHasDeeper(array $deptIds): array
{
$names = [];
if ($deptIds !== []) {
$names = Dept::whereIn('id', $deptIds)->whereNull('delete_time')->column('name', 'id');
}
$hasDeeper = false;
foreach ($deptIds as $id) {
$name = (string) ($names[$id] ?? '');
if ($name !== '' && mb_strpos($name, '一中心') === false && mb_strpos($name, '二中心') === false) {
$hasDeeper = true;
break;
}
}
if (!$hasDeeper) {
return $deptIds;
}
$kept = [];
foreach ($deptIds as $id) {
$name = (string) ($names[$id] ?? '');
if ($name !== '' && (mb_strpos($name, '一中心') !== false || mb_strpos($name, '二中心') !== false)) {
continue;
}
$kept[] = $id;
}
return $kept;
}
private static function normalizePersonName(string $raw): string
{
$value = trim($raw);
if ($value === '') {
return '';
}
$value = preg_replace('/[(][^)]*[)]/u', '', $value) ?? $value;
$value = preg_replace('/[\s\x{3000}]+/u', '', $value) ?? $value;
$suffixes = ['组长', '负责人', '主管', '主任', '医师', '医生', '医助', '老师'];
foreach ($suffixes as $suffix) {
$len = mb_strlen($suffix);
while (mb_strlen($value) > $len && mb_substr($value, -$len) === $suffix) {
$value = mb_substr($value, 0, mb_strlen($value) - $len);
}
}
return trim($value);
}
/**
* @return int[]
*/
private static function adminsInOwnDeptTree(int $adminId): array
{
$ownDeptIds = array_values(array_unique(array_filter(
array_map('intval', AdminDept::where('admin_id', $adminId)->column('dept_id')),
static fn (int $id): bool => $id > 0
)));
if ($ownDeptIds === []) {
return $adminId > 0 ? [$adminId] : [];
}
$deptIds = [];
foreach ($ownDeptIds as $deptId) {
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
$id = (int) $id;
if ($id > 0) {
$deptIds[] = $id;
}
}
}
$deptIds = array_values(array_unique($deptIds));
if ($deptIds === []) {
return [$adminId];
}
$adminIds = array_values(array_unique(array_filter(
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
static fn (int $id): bool => $id > 0
)));
if ($adminId > 0 && !in_array($adminId, $adminIds, true)) {
$adminIds[] = $adminId;
}
return $adminIds;
}
/**
* @return string[]
*/
private static function roleNames(int $adminId): array
{
if ($adminId <= 0) {
return [];
}
$roleIds = array_values(array_unique(array_filter(
array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id')),
static fn (int $id): bool => $id > 0
)));
if ($roleIds === []) {
return [];
}
$names = SystemRole::whereIn('id', $roleIds)
->whereNull('delete_time')
->column('name');
return array_values(array_filter(array_map('strval', $names)));
}
/**
* @param string[] $roleNames
* @param string[] $needles
*/
private static function roleNamesMatch(array $roleNames, array $needles): bool
{
foreach ($roleNames as $name) {
$name = trim($name);
if ($name === '') {
continue;
}
foreach ($needles as $needle) {
if ($name === $needle || mb_strpos($name, $needle) !== false) {
return true;
}
}
}
return false;
}
}
+4 -3
View File
@@ -114,9 +114,10 @@ class AdminAuthCache extends BaseCache
*/ */
public function clearAuthCache() public function clearAuthCache()
{ {
$this->tag($this->cacheUrlKey)->clear(); // BaseCache::set() 使用类名作为标签,并没有使用 cacheUrlKey 作为标签;
return true; // 这里必须直接删除单账号缓存键,否则角色变更后旧权限会继续保留一小时。
return $this->delete($this->cacheUrlKey);
} }
} }
@@ -19,8 +19,8 @@ use think\facade\Config;
* - DEPT (3) = 仅本部门(取 admin 全部部门的并集,不含子孙) * - DEPT (3) = 仅本部门(取 admin 全部部门的并集,不含子孙)
* - SELF (4) = 仅本人 * - SELF (4) = 仅本人
* *
* 多角色时取「最严格」可见范围 = data_scope 最大值(1=全部 4=仅本人), * 多角色时2/3/4 范围按授权并集取最宽范围;迁移期遗留的普通 scope=1 角色不会
* 与常见「数据权限取交集」一致,避免挂了一个「全部」角色就把其它角色的部门范围冲掉 * 自动冲掉有限范围。只有明确的管理员角色、root exempt_roles 才能在组合角色中放开全部
* root 管理员固定为 ALL。未挂任何部门时,范围退化为 SELF(可由 config 关闭)。 * root 管理员固定为 ALL。未挂任何部门时,范围退化为 SELF(可由 config 关闭)。
* *
* 关键返回:`getVisibleAdminIds` 返回 int[](可见 admin_id 集合)或 nullALL = 不过滤)。 * 关键返回:`getVisibleAdminIds` 返回 int[](可见 admin_id 集合)或 nullALL = 不过滤)。
@@ -32,6 +32,9 @@ class DataScopeService
public const SCOPE_DEPT = 3; public const SCOPE_DEPT = 3;
public const SCOPE_SELF = 4; public const SCOPE_SELF = 4;
/** @var string[] 明确允许在多角色组合中放开全部数据的内置角色名。 */
private const ALL_SCOPE_ROLE_NAMES = ['管理员', '系统管理员'];
/** /**
* 计算当前 admin 的有效数据范围。 * 计算当前 admin 的有效数据范围。
*/ */
@@ -51,18 +54,59 @@ class DataScopeService
if ($roleIds === []) { if ($roleIds === []) {
return self::SCOPE_SELF; return self::SCOPE_SELF;
} }
$scopes = SystemRole::whereIn('id', $roleIds) $roleRows = SystemRole::whereIn('id', $roleIds)
->whereNull('delete_time') ->whereNull('delete_time')
->column('data_scope'); ->field('id,name,data_scope')
$scopes = array_values(array_filter(array_map('intval', $scopes), static function (int $v): bool { ->select()
return $v >= self::SCOPE_ALL && $v <= self::SCOPE_SELF; ->toArray();
}));
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到「仅本人」,避免误放开到全站 return self::mergeRoleScopes($roleRows);
if ($scopes === []) { }
/**
* 合并多个角色的数据范围。
*
* 历史迁移曾把全部旧角色默认成 scope=1;如果账号同时具有有限范围角色,普通的
* scope=1 视为功能角色而不参与放大,防止“医生 + 医助”等组合意外获得全站数据。
* 2/3/4 是嵌套授权,取最小值即可表达多个有效数据角色的可见范围并集。
*
* @param array<int,array<string,mixed>> $roleRows
*/
private static function mergeRoleScopes(array $roleRows): int
{
$validRows = [];
foreach ($roleRows as $roleRow) {
$scope = (int) ($roleRow['data_scope'] ?? 0);
if ($scope < self::SCOPE_ALL || $scope > self::SCOPE_SELF) {
continue;
}
$validRows[] = [
'name' => trim((string) ($roleRow['name'] ?? '')),
'scope' => $scope,
];
}
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到本人。
if ($validRows === []) {
return self::SCOPE_SELF; return self::SCOPE_SELF;
} }
return (int) max($scopes); foreach ($validRows as $roleRow) {
if ($roleRow['scope'] === self::SCOPE_ALL
&& in_array($roleRow['name'], self::ALL_SCOPE_ROLE_NAMES, true)) {
return self::SCOPE_ALL;
}
}
$boundedScopes = array_column(array_values(array_filter(
$validRows,
static fn (array $roleRow): bool => $roleRow['scope'] > self::SCOPE_ALL
)), 'scope');
if ($boundedScopes !== []) {
return (int) min($boundedScopes);
}
// 只有普通 scope=1 角色时保持原有“全部数据”行为。
return self::SCOPE_ALL;
} }
/** /**
+2 -1
View File
@@ -140,7 +140,8 @@ return [
/* /*
* 数据隔离(按部门):全局可用,角色上的 data_scope 控制范围 * 数据隔离(按部门):全局可用,角色上的 data_scope 控制范围
* 1=全部 2=本部门及下级 3=仅本部门 4=仅本人 * 1=全部 2=本部门及下级 3=仅本部门 4=仅本人
* root 管理员永远视为「全部」;多角色取最严格范围(数值最大,与 DataScopeService 一致) * root/管理员/豁免角色永远视为「全部」;多角色的有限范围取授权并集(数值最小)。
* 历史默认 scope=1 的普通功能角色与有限范围角色组合时不参与放大,避免意外越权。
*/ */
'data_scope' => [ 'data_scope' => [
// 顶层总开关:false 时所有列表不应用按部门的数据隔离(仍保留老的白名单规则) // 顶层总开关:false 时所有列表不应用按部门的数据隔离(仍保留老的白名单规则)
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\auth\AdminLogic;
use app\common\cache\AdminAuthCache;
require dirname(__DIR__) . '/vendor/autoload.php';
function adminMultiRoleExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$adminReflection = new ReflectionClass(AdminLogic::class);
$normalizeRoleIds = $adminReflection->getMethod('normalizeRoleIds');
$roleIdsChanged = $adminReflection->getMethod('roleIdsChanged');
$normalizeRoleIds->setAccessible(true);
$roleIdsChanged->setAccessible(true);
adminMultiRoleExpect(
$normalizeRoleIds->invoke(null, ['7', 2, 7, 0, -1]) === [2, 7],
'Role ids must be normalized, deduplicated and sorted'
);
adminMultiRoleExpect(
$roleIdsChanged->invoke(null, [2], [2, 7]) === true,
'Adding a role must invalidate the existing token'
);
adminMultiRoleExpect(
$roleIdsChanged->invoke(null, [2, 7], [2]) === true,
'Removing a role must invalidate the existing token'
);
adminMultiRoleExpect(
$roleIdsChanged->invoke(null, [2, 7], [7, 2]) === false,
'Changing only role order must not invalidate the token'
);
$cacheMethod = new ReflectionMethod(AdminAuthCache::class, 'clearAuthCache');
$sourceLines = file($cacheMethod->getFileName());
$methodSource = implode('', array_slice(
$sourceLines,
$cacheMethod->getStartLine() - 1,
$cacheMethod->getEndLine() - $cacheMethod->getStartLine() + 1
));
adminMultiRoleExpect(
str_contains($methodSource, 'delete($this->cacheUrlKey)'),
'Single-admin permission cache must delete its concrete cache key'
);
adminMultiRoleExpect(
!str_contains($methodSource, 'tag($this->cacheUrlKey)'),
'Single-admin permission cache must not clear a tag that was never assigned'
);
echo "AdminMultiRoleRegressionTest passed\n";
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use app\common\service\DataScope\DataScopeService;
require dirname(__DIR__) . '/vendor/autoload.php';
function dataScopeMultiRoleExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$mergeRoleScopes = (new ReflectionClass(DataScopeService::class))->getMethod('mergeRoleScopes');
$mergeRoleScopes->setAccessible(true);
$role = static fn (string $name, int $scope): array => ['name' => $name, 'data_scope' => $scope];
dataScopeMultiRoleExpect(
$mergeRoleScopes->invoke(null, [$role('医助', 4)]) === DataScopeService::SCOPE_SELF,
'Single assistant role must remain self-only'
);
dataScopeMultiRoleExpect(
$mergeRoleScopes->invoke(null, [$role('诊室组长', 3), $role('医助', 4)]) === DataScopeService::SCOPE_DEPT,
'Group leader plus assistant must use the group scope'
);
dataScopeMultiRoleExpect(
$mergeRoleScopes->invoke(null, [$role('经理', 2), $role('诊室组长', 3), $role('医助', 4)])
=== DataScopeService::SCOPE_DEPT_AND_CHILD,
'Manager plus narrower roles must use department-and-child scope'
);
dataScopeMultiRoleExpect(
$mergeRoleScopes->invoke(null, [$role('医生', 1), $role('医助', 4)]) === DataScopeService::SCOPE_SELF,
'Legacy all-scope functional role must not widen a bounded role'
);
dataScopeMultiRoleExpect(
$mergeRoleScopes->invoke(null, [$role('管理员', 1), $role('医助', 4)]) === DataScopeService::SCOPE_ALL,
'Explicit administrator role must retain all-data scope'
);
dataScopeMultiRoleExpect(
$mergeRoleScopes->invoke(null, [$role('下单', 1)]) === DataScopeService::SCOPE_ALL,
'A standalone legacy all-scope role must keep its existing behavior'
);
dataScopeMultiRoleExpect(
$mergeRoleScopes->invoke(null, [$role('脏数据', 0)]) === DataScopeService::SCOPE_SELF,
'Invalid role scopes must fail closed to self-only'
);
echo "DataScopeMultiRoleTest passed\n";
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
use app\common\service\DataScope\DataScopeService;
require dirname(__DIR__) . '/vendor/autoload.php';
function conversionRankingExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$reflection = new ReflectionClass(FirstVisitConversionLogic::class);
$rankingKind = $reflection->getMethod('rankingKind');
$rankingRows = $reflection->getMethod('rankingRows');
$topRows = $reflection->getMethod('topRows');
$rankingKind->setAccessible(true);
$rankingRows->setAccessible(true);
$topRows->setAccessible(true);
$member = static fn (int $id, string $name, int $orders, float $amount): array => [
'id' => "M{$id}_11",
'admin_id' => $id,
'name' => $name,
'type' => 'member',
'completed_order_count' => $orders,
'completed_order_amount' => $amount,
'children' => [],
];
$groupRows = [[
'id' => 10,
'name' => '一诊中心',
'children' => [
[
'id' => 11,
'name' => '一组',
'completed_order_count' => 8,
'completed_order_amount' => 800,
'children' => [$member(101, '甲', 5, 500), $member(102, '乙', 3, 300)],
],
[
'id' => 12,
'name' => '二组',
'completed_order_count' => 6,
'completed_order_amount' => 600,
'children' => [$member(103, '丙', 6, 600)],
],
],
]];
conversionRankingExpect(
$rankingKind->invoke(null, DataScopeService::SCOPE_SELF, 0) === 'hidden',
'Self-only data range must hide rankings'
);
conversionRankingExpect(
$rankingRows->invoke(null, $groupRows, 'hidden') === [],
'Hidden ranking mode must not return ranking rows'
);
conversionRankingExpect(
$rankingKind->invoke(null, DataScopeService::SCOPE_DEPT_AND_CHILD, 101) === 'hidden',
'Selecting one employee must switch the ranking to personal mode'
);
$memberRows = $rankingRows->invoke(null, $groupRows, 'member');
conversionRankingExpect(count($memberRows) === 3, 'Member ranking mode must include visible group members');
$rankedMembers = $topRows->invoke(null, $memberRows, 'completed_order_count');
conversionRankingExpect(
array_column($rankedMembers, 'name') === ['丙', '甲', '乙'],
'Member ranking must be ordered by the selected metric'
);
conversionRankingExpect(
$rankedMembers[0]['id'] === 'M103_11',
'Member ranking must preserve its string row key'
);
$teamRows = $rankingRows->invoke(null, $groupRows, 'group');
conversionRankingExpect(
array_column($teamRows, 'name') === ['一组', '二组'],
'Group ranking mode must use direct child departments'
);
conversionRankingExpect(
$rankingKind->invoke(null, DataScopeService::SCOPE_ALL, 0) === 'group',
'All-data range must use the group ranking dimension'
);
echo "FirstVisitConversionRankingTest passed\n";