Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a797743aa4 | ||
|
|
abb4aced1c | ||
|
|
4ee8a8e98b | ||
|
|
7ddb4882e3 | ||
|
|
c2b7018a22 | ||
|
|
57a7c415a1 | ||
|
|
2425fb60d0 |
@@ -0,0 +1,202 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface MyPatientListParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
keyword?: string
|
||||
status_filter?: '' | 'unconfirmed' | 'booked' | 'completed' | 'missed'
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
export function myPatientLists(params: MyPatientListParams) {
|
||||
return request.get({ url: '/firstvisit.myPatient/lists', params })
|
||||
}
|
||||
|
||||
export interface MyPatientOrderListParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
keyword?: string
|
||||
prescription_audit_status?: '' | number
|
||||
payment_slip_audit_status?: '' | number
|
||||
fulfillment_status?: '' | number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
export function myPatientOrderLists(params: MyPatientOrderListParams) {
|
||||
return request.get({ url: '/firstvisit.myPatient/orders', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderDetail(params: { id: number }) {
|
||||
return request.get({ url: '/firstvisit.myPatient/orderDetail', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderEdit(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderEdit', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderAuditPrescription(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderAuditPrescription', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderRevokeRxAudit(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderRevokeRxAudit', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderAuditPayment(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderAuditPayment', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderRevokePayAudit(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderRevokePayAudit', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderDdcode(params: {
|
||||
id: number
|
||||
express_company: string
|
||||
tracking_number: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderDdcode', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderShip(params: {
|
||||
id: number
|
||||
ship_mode?: 'gancao' | 'direct'
|
||||
express_company: string
|
||||
tracking_number: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderShip', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderAddPayOrder(params: {
|
||||
id: number
|
||||
order_type: number
|
||||
pay_amount: number
|
||||
pay_remark?: string
|
||||
completion_request?: number
|
||||
pay_create_type?: 'fubei' | 'express_cod'
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderAddPayOrder', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderComplete', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderRefund', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderWithdraw(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderWithdraw', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderUploadToPharmacy(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderUploadToPharmacy', params })
|
||||
}
|
||||
|
||||
export interface MyPatientProgressListParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
keyword?: string
|
||||
status?: '' | 1 | 3 | 4
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
export function myPatientProgressLists(params: MyPatientProgressListParams) {
|
||||
return request.get({ url: '/firstvisit.myPatient/progress', params })
|
||||
}
|
||||
|
||||
export function myPatientCreateAppointment(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.myPatient/createAppointment', params })
|
||||
}
|
||||
|
||||
export function myPatientCancelAppointment(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/cancelAppointment', params })
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionParams {
|
||||
time_type: 'today' | 'week' | 'month' | 'quarter' | 'year'
|
||||
dept_id?: number
|
||||
assistant_id?: number
|
||||
}
|
||||
|
||||
/** 一诊综合数据转化:服务端按当前角色 DataScope 与所选部门/员工取交集。 */
|
||||
export function firstVisitConversionOverview(params: FirstVisitConversionParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.conversion/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export interface FirstVisitRegistrationStatsParams {
|
||||
time_type: 'today' | 'week' | 'month'
|
||||
dept_id?: number
|
||||
assistant_id?: number
|
||||
}
|
||||
|
||||
/** 一诊挂号统计:部门和员工参数只会在服务端 DataScope 权限范围内继续收窄。 */
|
||||
export function firstVisitRegistrationStatsOverview(params: FirstVisitRegistrationStatsParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.registrationStats/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export interface FirstVisitDoctorDashboardParams {
|
||||
time_type: 'today' | 'week' | 'month'
|
||||
dept_id?: number
|
||||
doctor_id?: number
|
||||
active_only?: 0 | 1
|
||||
alert_threshold?: number
|
||||
}
|
||||
|
||||
/** 一诊医生看板:医生与经手医助范围均由服务端根据当前角色和部门权限计算。 */
|
||||
export function firstVisitDoctorDashboardOverview(params: FirstVisitDoctorDashboardParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.doctorDashboard/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function wecomPromotionOverview() {
|
||||
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
|
||||
}
|
||||
|
||||
export function wecomPromotionAuthorizationUrl() {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/authorizationUrl' })
|
||||
}
|
||||
|
||||
export function wecomPromotionVerifyAccount(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/verifyAccount', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionToggleLink(params: { id: number; status: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/toggleLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeleteLink(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deleteLink', params })
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/** 角色数据驾驶舱:服务端统一按当前管理员的数据范围聚合。 */
|
||||
export function performanceDashboardOverview() {
|
||||
return request.get(
|
||||
{ url: '/stats.performanceDashboard/overview', timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function getConversionStatsOverview(params: any) {
|
||||
return request.get({ url: '/stats.conversion/overview', params })
|
||||
}
|
||||
|
||||
+21
-10
@@ -682,7 +682,7 @@
|
||||
|
||||
<!-- 物流轨迹 -->
|
||||
<el-card
|
||||
v-if="String(detailData.tracking_number || '').trim()"
|
||||
v-if="loadRelatedData && String(detailData.tracking_number || '').trim()"
|
||||
shadow="never"
|
||||
class="po-panel po-panel-logistics"
|
||||
:class="
|
||||
@@ -802,6 +802,7 @@
|
||||
|
||||
<!-- 操作日志 -->
|
||||
<el-card
|
||||
v-if="loadRelatedData"
|
||||
v-perms="['tcm.prescriptionOrder/logs']"
|
||||
shadow="never"
|
||||
class="po-panel border-gray-100 mt-4"
|
||||
@@ -1057,11 +1058,17 @@ const props = withDefaults(
|
||||
gancaoPreviewLoading?: boolean
|
||||
/** 嵌套在其他抽屉内时需要 append-to-body */
|
||||
appendToBody?: boolean
|
||||
/** 自定义详情请求;用于在有独立行级权限边界的页面安全复用抽屉。 */
|
||||
detailLoader?: (params: { id: number }) => Promise<any>
|
||||
/** 是否加载原订单页的物流、日志和未关联支付单等附加接口。 */
|
||||
loadRelatedData?: boolean
|
||||
}>(),
|
||||
{
|
||||
readonly: false,
|
||||
gancaoPreviewLoading: false,
|
||||
appendToBody: false
|
||||
appendToBody: false,
|
||||
detailLoader: undefined,
|
||||
loadRelatedData: true
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1649,6 +1656,10 @@ async function updateJdLogistics() {
|
||||
}
|
||||
|
||||
// ─── 打开 / 刷新 ───
|
||||
function requestDetail(id: number) {
|
||||
return props.detailLoader ? props.detailLoader({ id }) : prescriptionOrderDetail({ id })
|
||||
}
|
||||
|
||||
async function open(id: number) {
|
||||
// 页面级同参数字典请求会取消抽屉挂载时的那次(axios 去重取消),打开时兜底重试
|
||||
void loadServicePackageOptions()
|
||||
@@ -1663,22 +1674,22 @@ async function open(id: number) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res: any = await prescriptionOrderDetail({ id })
|
||||
const res: any = await requestDetail(id)
|
||||
const d = res?.data ?? res ?? null
|
||||
detailData.value = d
|
||||
if (d) {
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||
if (String(d.tracking_number || '').trim()) {
|
||||
if (props.loadRelatedData && String(d.tracking_number || '').trim()) {
|
||||
fetchLogisticsTrace()
|
||||
}
|
||||
fetchLogs(id)
|
||||
if (props.loadRelatedData) fetchLogs(id)
|
||||
|
||||
// 加载未关联的支付单
|
||||
const diagId = d.diagnosis_id
|
||||
const linkedIds = d.pay_order_ids || []
|
||||
if (diagId) {
|
||||
if (props.loadRelatedData && diagId) {
|
||||
void loadDetailUnlinkedPayOrders(diagId, id, linkedIds)
|
||||
}
|
||||
}
|
||||
@@ -1695,20 +1706,20 @@ async function refresh() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
try {
|
||||
const res: any = await prescriptionOrderDetail({ id })
|
||||
const res: any = await requestDetail(id)
|
||||
const d = res?.data ?? res ?? null
|
||||
if (!d) return
|
||||
const prevTracking = String(detailData.value?.tracking_number || '').trim()
|
||||
detailData.value = d
|
||||
const nextTracking = String(d.tracking_number || '').trim()
|
||||
if (nextTracking && nextTracking !== prevTracking) {
|
||||
if (props.loadRelatedData && nextTracking && nextTracking !== prevTracking) {
|
||||
bumpLogisticsTraceRequestToken()
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
void fetchLogisticsTrace()
|
||||
}
|
||||
void fetchLogs(id)
|
||||
if (props.loadRelatedData) void fetchLogs(id)
|
||||
const diagId = d.diagnosis_id
|
||||
if (diagId) {
|
||||
if (props.loadRelatedData && diagId) {
|
||||
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
{{ listStats.periodLine }}
|
||||
</p>
|
||||
<!-- <p class="relative mt-1.5 text-xs text-slate-400 leading-relaxed">
|
||||
业绩 = 除业务订单已取消(4)外关联支付金额;下方为合计与已取消明细
|
||||
业绩 = 排除业务订单已取消(4)、拒收(9)、退款(10)后的关联支付金额;下方为合计与排除项明细
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -2920,7 +2920,7 @@ const listStats = computed(() => {
|
||||
const oC = hasOrderSplit ? n(ex?.stats_order_amount_cancelled) : 0
|
||||
const pNc = hasPaySplit ? n(ex?.stats_linked_pay_amount_not_cancelled) : n(pay)
|
||||
const pC = hasPaySplit ? n(ex?.stats_linked_pay_amount_cancelled) : 0
|
||||
// 业绩 = 除履约已取消(fulfillment_status=4)外的全部订单金额;优先用后端显式字段,兜底到 not_cancelled
|
||||
// 业绩 = 排除履约已取消(4)、拒收(9)、退款(10)后的订单金额;优先用后端显式字段,兜底到 not_cancelled
|
||||
const oPerf = ex?.stats_order_amount_performance !== undefined
|
||||
? n(ex?.stats_order_amount_performance)
|
||||
: oNc
|
||||
@@ -2960,7 +2960,7 @@ const listStats = computed(() => {
|
||||
payHeading: `${headPrefix}业绩(关联实付)`,
|
||||
periodLine,
|
||||
scopeHint,
|
||||
orderSplitHint: '业绩 = 除履约已取消(4)外全部订单金额;下方为合计与已取消明细'
|
||||
orderSplitHint: '业绩 = 排除履约已取消(4)、拒收(9)、退款(10)后的订单金额;下方为合计与排除项明细'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<div class="conversion-page" v-loading="loading" element-loading-text="正在汇总权限范围内的数据">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h1>综合数据转化</h1>
|
||||
<p>{{ scopeDescription }}</p>
|
||||
</div>
|
||||
<div class="heading-meta">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
|
||||
<span v-if="dashboard.meta.generated_at">更新于 {{ dashboard.meta.generated_at }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip">
|
||||
<div class="filter-item filter-item--time">
|
||||
<span class="filter-label">时间范围</span>
|
||||
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">搜索员工</span>
|
||||
<el-select
|
||||
v-model="query.assistant_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部员工"
|
||||
class="employee-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in dashboard.filters.assistants"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="Number(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">部门</span>
|
||||
<el-tree-select
|
||||
v-model="query.dept_id"
|
||||
:data="dashboard.filters.departments"
|
||||
:props="deptTreeProps"
|
||||
node-key="id"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="全部可见部门"
|
||||
class="dept-select"
|
||||
@change="handleDeptChange"
|
||||
/>
|
||||
</div>
|
||||
<span class="range-text">{{ dashboard.meta.start_date }} 至 {{ dashboard.meta.end_date }}</span>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="综合转化指标">
|
||||
<article v-for="metric in metricCards" :key="metric.key" class="metric-card">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
|
||||
<small>{{ metric.hint }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>部门订单量占比</h2>
|
||||
<p>当前范围内审核通过的接诊诊单</p>
|
||||
</div>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
<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">
|
||||
<span class="bar-name" :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, maxOrderValue) }" /></div>
|
||||
<strong>{{ formatNumber(item.value) }} 单</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
|
||||
</article>
|
||||
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>部门金额占比</h2>
|
||||
<p>与诊单金额指标保持同一审核口径</p>
|
||||
</div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<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">
|
||||
<span class="bar-name" :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmountValue) }" /></div>
|
||||
<strong>{{ formatMoney(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel detail-panel">
|
||||
<div class="panel-heading panel-heading--table">
|
||||
<div>
|
||||
<h2>明细数据列表</h2>
|
||||
<p>部门层级汇总;父级包含其下级数据</p>
|
||||
</div>
|
||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||
</div>
|
||||
<el-table
|
||||
:data="dashboard.rows"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
class="detail-table"
|
||||
>
|
||||
<el-table-column prop="name" label="部门" min-width="230" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length }">
|
||||
{{ row.name }}
|
||||
</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" />
|
||||
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" />
|
||||
<el-table-column label="诊单金额" min-width="120" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接诊率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ROI" min-width="86" align="right">
|
||||
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel target-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>一诊诊金目标追踪</h2>
|
||||
<p>{{ dashboard.target.year }} 年 · 按当前角色、部门及员工筛选范围计算</p>
|
||||
</div>
|
||||
<span>{{ dashboard.target.department_count }} 个目标部门</span>
|
||||
</div>
|
||||
<div class="target-layout">
|
||||
<div class="target-progress-list">
|
||||
<div class="target-summary">
|
||||
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
|
||||
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
|
||||
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-copy">
|
||||
<span>年度范围目标</span>
|
||||
<b>{{ nullablePercent(dashboard.target.completion_rate) }}</b>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="progressValue(dashboard.target.completion_rate)"
|
||||
:show-text="false"
|
||||
:stroke-width="12"
|
||||
color="#0f9185"
|
||||
/>
|
||||
<small>已完成 {{ formatMoney(dashboard.target.actual_amount) }} / 目标 {{ formatMoney(dashboard.target.target_amount) }}</small>
|
||||
</div>
|
||||
|
||||
<div class="progress-block progress-block--month">
|
||||
<div class="progress-copy">
|
||||
<span>本月范围目标</span>
|
||||
<b>{{ nullablePercent(dashboard.target.current_month_rate) }}</b>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="progressValue(dashboard.target.current_month_rate)"
|
||||
:show-text="false"
|
||||
:stroke-width="12"
|
||||
color="#2f78df"
|
||||
/>
|
||||
<small>已完成 {{ formatMoney(dashboard.target.current_month_actual) }} / 目标 {{ formatMoney(dashboard.target.current_month_target) }}</small>
|
||||
</div>
|
||||
|
||||
<div v-if="Number(dashboard.target.target_amount) <= 0" class="target-empty-note">
|
||||
当前可见部门尚未维护本年度月度目标,实际诊单金额仍会正常统计。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trend-wrap">
|
||||
<div class="trend-title">
|
||||
<span>年度累计趋势</span>
|
||||
<div><i class="legend-line is-actual" />实际完成 <i class="legend-line is-target" />目标值</div>
|
||||
</div>
|
||||
<v-charts class="target-chart" :option="targetChartOption" autoresize />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitConversionPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, Refresh } from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
|
||||
|
||||
type MetricType = 'count' | 'money' | 'ratio'
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '', open_count_source: ''
|
||||
},
|
||||
filters: { departments: [] as any[], assistants: [] as Array<{ id: number; name: string }> },
|
||||
summary: {} as Record<string, any>,
|
||||
rankings: { orders: [] as any[], amounts: [] as any[] },
|
||||
rows: [] as any[],
|
||||
target: {
|
||||
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0, completion_rate: null as number | null,
|
||||
current_month_target: 0, current_month_actual: 0, current_month_rate: null as number | null,
|
||||
department_count: 0, months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
|
||||
}
|
||||
})
|
||||
|
||||
const dashboard = reactive(emptyDashboard())
|
||||
const loading = ref(false)
|
||||
const query = reactive<FirstVisitConversionParams>({ time_type: 'today', dept_id: undefined, assistant_id: undefined })
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' },
|
||||
{ label: '本季度', value: 'quarter' },
|
||||
{ label: '本年', value: 'year' }
|
||||
]
|
||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '企微新增客户' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成挂号' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '处方与支付审核通过' },
|
||||
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '与接诊诊单同口径' },
|
||||
{ key: 'avg_unit_price', label: '平均客单价', type: 'money', hint: '诊单金额 / 接诊诊单' },
|
||||
{ key: 'account_cost', label: '现金成本', type: 'money', hint: '当前范围内实际投放成本' },
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio', hint: '诊单金额 / 投放成本' }
|
||||
]
|
||||
|
||||
const scopeDescription = computed(() => {
|
||||
const parts = [`${dashboard.meta.time_label || '当前区间'}数据`, dashboard.meta.scope_label || '当前权限范围']
|
||||
if (dashboard.meta.selected_dept_name) parts.push(dashboard.meta.selected_dept_name)
|
||||
if (dashboard.meta.selected_assistant_name) parts.push(dashboard.meta.selected_assistant_name)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
|
||||
const maxAmountValue = computed(() => Math.max(0, ...dashboard.rankings.amounts.map(item => Number(item.value || 0))))
|
||||
const targetChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
color: ['#0f9185', '#2f78df'],
|
||||
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
|
||||
grid: { left: 62, right: 24, top: 28, bottom: 36 },
|
||||
xAxis: { type: 'category', boundaryGap: false, data: dashboard.target.months, axisLine: { lineStyle: { color: '#d9e1e9' } }, axisLabel: { color: '#718096' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#718096', formatter: (value: number) => compactNumber(value) }, splitLine: { lineStyle: { color: '#edf1f5' } } },
|
||||
series: [
|
||||
{ name: '实际完成', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 3 }, areaStyle: { color: 'rgba(15,145,133,.08)' }, data: dashboard.target.actual_cumulative },
|
||||
{ name: '目标值', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 2, type: 'dashed' }, data: dashboard.target.target_cumulative }
|
||||
]
|
||||
}))
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: any = await firstVisitConversionOverview(query)
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '综合数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeptChange() {
|
||||
query.assistant_id = undefined
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function formatMetric(key: string, type: MetricType) {
|
||||
const value = dashboard.summary[key]
|
||||
if (type === 'money') return formatMoney(value)
|
||||
if (type === 'ratio') return formatRatio(value)
|
||||
return formatNumber(value)
|
||||
}
|
||||
|
||||
function formatNumber(value: any) {
|
||||
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function formatMoney(value: any) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatPercent(value: any) {
|
||||
return `${Number(value || 0).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function nullablePercent(value: any) {
|
||||
return value === null || value === undefined ? '未设置' : formatPercent(value)
|
||||
}
|
||||
|
||||
function formatRatio(value: any) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function compactNumber(value: number) {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(0)}万`
|
||||
return String(Math.round(value))
|
||||
}
|
||||
|
||||
function barWidth(value: any, maximum: number) {
|
||||
if (maximum <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, Number(value || 0) / maximum * 100))}%`
|
||||
}
|
||||
|
||||
function progressValue(value: any) {
|
||||
return Math.max(0, Math.min(100, Number(value || 0)))
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversion-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-height: 640px;
|
||||
padding: 16px;
|
||||
color: #172033;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.panel,
|
||||
.metric-card {
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 15px 18px;
|
||||
border-radius: 10px;
|
||||
|
||||
h1 { margin: 0; font-size: 20px; font-weight: 750; }
|
||||
p { margin: 5px 0 0; color: #8590a2; font-size: 12px; }
|
||||
}
|
||||
|
||||
.heading-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #8994a5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #0d8077;
|
||||
}
|
||||
|
||||
.filter-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.filter-item { display: flex; align-items: center; gap: 8px; }
|
||||
.filter-label, .range-text { color: #748094; font-size: 12px; white-space: nowrap; }
|
||||
.range-text { margin-left: auto; }
|
||||
.employee-select { width: 190px; }
|
||||
.dept-select { width: 220px; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 100px;
|
||||
padding: 15px 17px;
|
||||
border-radius: 10px;
|
||||
|
||||
span, small { display: block; color: #748094; font-size: 12px; }
|
||||
strong { display: block; margin: 9px 0 7px; color: #111b2f; font-size: 25px; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
small { color: #a0a9b6; font-size: 11px; }
|
||||
}
|
||||
|
||||
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel { padding: 16px; border-radius: 10px; }
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 { margin: 0; font-size: 15px; font-weight: 700; }
|
||||
p { margin: 4px 0 0; color: #929dac; font-size: 11px; }
|
||||
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
|
||||
}
|
||||
|
||||
.bar-list { display: grid; gap: 13px; }
|
||||
.bar-row { display: grid; grid-template-columns: 110px minmax(80px, 1fr) 94px; align-items: center; gap: 10px; }
|
||||
.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-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.is-teal { background: #15998d; }
|
||||
.bar-track i.is-blue { background: #307bdf; }
|
||||
|
||||
.detail-panel { padding-bottom: 10px; }
|
||||
.detail-table {
|
||||
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; 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; }
|
||||
strong.is-parent { color: #172033; font-weight: 700; }
|
||||
}
|
||||
|
||||
.target-panel { padding-bottom: 18px; }
|
||||
.target-layout { display: grid; grid-template-columns: minmax(390px, .82fr) minmax(500px, 1.18fr); gap: 22px; }
|
||||
.target-progress-list { padding-right: 20px; border-right: 1px solid #e4e9ef; }
|
||||
.target-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||
.target-summary div { display: grid; gap: 5px; }
|
||||
.target-summary span { color: #8590a2; font-size: 11px; }
|
||||
.target-summary strong { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.target-summary strong.is-teal { color: #0f9185; }
|
||||
.progress-block + .progress-block { margin-top: 18px; }
|
||||
.progress-copy { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
||||
.progress-copy b { color: #263246; font-size: 13px; }
|
||||
.progress-block small { display: block; margin-top: 7px; color: #8a95a5; font-size: 11px; }
|
||||
.target-empty-note { margin-top: 16px; padding: 9px 11px; border-radius: 7px; color: #a36b1e; background: #fff7e8; font-size: 11px; }
|
||||
.trend-title { display: flex; align-items: center; justify-content: space-between; color: #2e3a4d; font-size: 12px; font-weight: 600; }
|
||||
.trend-title > div { display: flex; align-items: center; gap: 7px; color: #7f8a9b; font-size: 10px; font-weight: 400; }
|
||||
.legend-line { width: 18px; border-top: 2px solid; }
|
||||
.legend-line.is-actual { border-color: #0f9185; }
|
||||
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
|
||||
.target-chart { width: 100%; height: 260px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.range-text { margin-left: 0; }
|
||||
.target-layout { grid-template-columns: 1fr; }
|
||||
.target-progress-list { padding-right: 0; padding-bottom: 18px; border-right: 0; border-bottom: 1px solid #e4e9ef; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.conversion-page { padding: 10px; }
|
||||
.page-heading, .heading-meta { align-items: flex-start; flex-direction: column; }
|
||||
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
|
||||
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
|
||||
.employee-select, .dept-select { width: 100%; }
|
||||
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
|
||||
.target-summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,752 @@
|
||||
<template>
|
||||
<div class="doctor-dashboard" v-loading="loading" element-loading-text="正在汇总医生经营数据">
|
||||
<header class="page-heading">
|
||||
<div class="heading-copy">
|
||||
<span class="heading-mark"><el-icon><DataLine /></el-icon></span>
|
||||
<div>
|
||||
<h1>医生看板</h1>
|
||||
<p>从挂号、面诊到接诊成交,统一观察医生经营表现</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
|
||||
<span v-if="dashboard.meta.generated_at" class="update-time">更新于 {{ dashboard.meta.generated_at }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
|
||||
<el-button :icon="Download" @click="exportRows">导出</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip">
|
||||
<div class="filter-item filter-item--time">
|
||||
<span>时间范围</span>
|
||||
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
|
||||
</div>
|
||||
<div v-if="dashboard.filters.can_filter_department" class="filter-item">
|
||||
<span>所属部门</span>
|
||||
<el-tree-select
|
||||
v-model="query.dept_id"
|
||||
:data="dashboard.filters.departments"
|
||||
:props="deptTreeProps"
|
||||
node-key="id"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="全部可见部门"
|
||||
class="dept-select"
|
||||
@change="handleDepartmentChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span>医生</span>
|
||||
<el-select
|
||||
v-model="query.doctor_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部医生"
|
||||
class="doctor-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="doctor in dashboard.filters.doctors"
|
||||
:key="doctor.id"
|
||||
:value="Number(doctor.id)"
|
||||
:label="`${doctor.name}${Number(doctor.disable) === 1 ? '(停用)' : ''}`"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item filter-item--status">
|
||||
<span>医生范围</span>
|
||||
<el-segmented v-model="query.active_only" :options="doctorStatusOptions" @change="handleActiveChange" />
|
||||
</div>
|
||||
<div class="view-switch">
|
||||
<button :class="{ active: viewMode === 'overview' }" @click="viewMode = 'overview'">诊断总览</button>
|
||||
<button :class="{ active: viewMode === 'detail' }" @click="viewMode = 'detail'">医生明细</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="医生经营核心指标">
|
||||
<article class="metric-card metric-card--teal">
|
||||
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
|
||||
<div>
|
||||
<span>总挂号</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.appointment_total) }}</strong>
|
||||
<small>完成 {{ formatNumber(dashboard.summary.interview_count) }} · 完成率 {{ formatPercent(dashboard.summary.appointment_completion_rate) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--green">
|
||||
<div class="metric-icon"><el-icon><User /></el-icon></div>
|
||||
<div>
|
||||
<span>总面诊</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.interview_count) }}</strong>
|
||||
<small>过号 {{ formatNumber(dashboard.summary.missed_count) }} · 取消 {{ formatNumber(dashboard.summary.cancelled_count) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--indigo">
|
||||
<div class="metric-icon"><el-icon><Tickets /></el-icon></div>
|
||||
<div>
|
||||
<span>总诊单</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.order_count) }}</strong>
|
||||
<small>接诊转化率 {{ formatPercent(dashboard.summary.receive_conversion_rate) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--blue">
|
||||
<div class="metric-icon"><el-icon><Money /></el-icon></div>
|
||||
<div>
|
||||
<span>总成交金额</span>
|
||||
<strong>{{ formatMoney(dashboard.summary.deal_amount) }}</strong>
|
||||
<small>客单价 {{ nullableMoney(dashboard.summary.avg_order_amount) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--cyan">
|
||||
<div class="metric-icon"><el-icon><CircleCheck /></el-icon></div>
|
||||
<div>
|
||||
<span>挂号完成率</span>
|
||||
<strong>{{ formatPercent(dashboard.summary.appointment_completion_rate) }}</strong>
|
||||
<small>{{ dashboard.meta.time_label }} · {{ dashboard.meta.doctor_count }} 位有数据医生</small>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<template v-if="viewMode === 'overview'">
|
||||
<section class="two-column-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>成交金额 TOP</h2><p>按有效诊单金额从高到低</p></div>
|
||||
<span>{{ dashboard.meta.time_label }}</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.doctor_id}`" class="bar-row">
|
||||
<b>{{ index + 1 }}</b>
|
||||
<span :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmount) }" /></div>
|
||||
<strong>{{ formatMoney(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="当前范围暂无成交金额" />
|
||||
</article>
|
||||
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>接诊转化率 TOP</h2><p>有效诊单数 ÷ 完成面诊数</p></div>
|
||||
<span>{{ dashboard.meta.time_label }}</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.conversion.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.conversion" :key="`rate-${item.doctor_id}`" class="bar-row">
|
||||
<b>{{ index + 1 }}</b>
|
||||
<span :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: percentageWidth(item.value) }" /></div>
|
||||
<strong>{{ formatPercent(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="当前范围暂无转化数据" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="two-column-grid analytics-grid">
|
||||
<article class="panel funnel-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>经营转化漏斗</h2><p>挂号 → 面诊 → 接诊 → 成交</p></div>
|
||||
<span>人数 / 单数</span>
|
||||
</div>
|
||||
<div class="funnel-wrap">
|
||||
<div
|
||||
v-for="(stage, index) in dashboard.funnel"
|
||||
:key="stage.key"
|
||||
class="funnel-stage"
|
||||
:class="`stage-${index + 1}`"
|
||||
:style="{ width: `${100 - index * 15}%` }"
|
||||
>
|
||||
<span>{{ stage.label }}</span><strong>{{ formatNumber(stage.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="funnel-insight">
|
||||
最大流失发生在 <strong>{{ funnelLoss.label }}</strong>,流失 {{ formatNumber(funnelLoss.value) }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel trend-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>近 30 天成交金额趋势</h2><p>{{ dashboard.trend.start_date }} 至 {{ dashboard.trend.end_date }}</p></div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<v-charts class="trend-chart" :option="trendChartOption" autoresize />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel alert-panel" :class="{ 'has-alerts': dashboard.alerts.length }">
|
||||
<div class="panel-heading alert-heading">
|
||||
<div>
|
||||
<h2><el-icon><WarningFilled /></el-icon>需关注医生</h2>
|
||||
<p>仅对完成过面诊的医生计算接诊转化预警</p>
|
||||
</div>
|
||||
<div class="threshold-control">
|
||||
<span>接诊转化率低于</span>
|
||||
<el-select v-model="query.alert_threshold" class="threshold-select" @change="loadDashboard">
|
||||
<el-option v-for="value in thresholdOptions" :key="value" :label="`${value}%`" :value="value" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="dashboard.alerts.length" class="alert-list">
|
||||
<div v-for="item in dashboard.alerts" :key="item.doctor_id" class="alert-row">
|
||||
<span class="alert-icon">!</span>
|
||||
<div class="alert-doctor"><strong>{{ item.doctor_name }}</strong><small>{{ item.department_name }}</small></div>
|
||||
<span class="severity" :class="`is-${item.severity}`">{{ item.severity === 'high' ? '重点关注' : '低于阈值' }}</span>
|
||||
<span class="alert-data">面诊 {{ item.interview_count }} / 接诊 {{ item.order_count }}</span>
|
||||
<p>{{ item.suggestion }}</p>
|
||||
<strong class="alert-rate">{{ formatPercent(item.rate) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="alert-empty">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
当前范围内暂无低于 {{ query.alert_threshold }}% 的医生
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section v-else class="panel detail-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>医生明细 · 按成交金额排序</h2><p>低于接诊率预警线的医生整行标红,过号与取消分别展示</p></div>
|
||||
<span>{{ businessRows.length }} 位有数据医生</span>
|
||||
</div>
|
||||
<el-table
|
||||
:data="visibleDetailRows"
|
||||
class="detail-table"
|
||||
:default-sort="{ prop: 'deal_amount', order: 'descending' }"
|
||||
:row-class-name="detailRowClassName"
|
||||
>
|
||||
<el-table-column prop="doctor_name" label="医生" min-width="130" fixed="left" sortable>
|
||||
<template #default="{ row }"><strong>{{ row.doctor_name }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" min-width="90" sortable>
|
||||
<template #default="{ row }">
|
||||
<span class="status-pill" :class="{ disabled: row.status === 'disabled' }">
|
||||
{{ row.status === 'disabled' ? '停用' : '活跃' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appointment_total" label="挂号" min-width="90" sortable />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="90" sortable />
|
||||
<el-table-column prop="order_count" label="接诊" min-width="90" sortable />
|
||||
<el-table-column prop="receive_conversion_rate" label="接诊率" min-width="110" sortable>
|
||||
<template #default="{ row }">
|
||||
<span :class="conversionClass(row.receive_conversion_rate)">
|
||||
<span v-if="isLowConversion(row)" class="warning-mark">△</span>{{ formatPercent(row.receive_conversion_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deal_amount" label="成交金额" min-width="135" sortable>
|
||||
<template #default="{ row }"><strong class="money-text">{{ formatDetailMoney(row.deal_amount) }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过号/取消" min-width="110">
|
||||
<template #default="{ row }">{{ formatNumber(row.appointment_missed) }}/{{ formatNumber(row.appointment_cancelled) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button class="detail-action" type="primary" link @click="openDoctorDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限和筛选范围内暂无医生数据" /></template>
|
||||
</el-table>
|
||||
<button
|
||||
v-if="!query.doctor_id && zeroDataRows.length"
|
||||
type="button"
|
||||
class="zero-doctor-toggle"
|
||||
@click="showZeroRows = !showZeroRows"
|
||||
>
|
||||
<span>{{ showZeroRows ? '▾' : '▸' }}</span>
|
||||
{{ showZeroRows ? '收起' : '已隐藏' }} {{ zeroDataRows.length }} 位无业务数据医生{{ showZeroRows ? '' : '(点击展开)' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<footer class="data-note">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ dashboard.meta.appointment_rule }};{{ dashboard.meta.performance_rule }}。</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitDoctorDashboardPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Calendar,
|
||||
CircleCheck,
|
||||
DataLine,
|
||||
Download,
|
||||
InfoFilled,
|
||||
Lock,
|
||||
Money,
|
||||
Refresh,
|
||||
Tickets,
|
||||
User,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import {
|
||||
firstVisitDoctorDashboardOverview,
|
||||
type FirstVisitDoctorDashboardParams
|
||||
} from '@/api/first_visit'
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'month', time_label: '本月', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', scope_kind: '', selected_dept_name: '', selected_doctor_name: '',
|
||||
doctor_count: 0, appointment_rule: '', performance_rule: ''
|
||||
},
|
||||
filters: {
|
||||
departments: [] as any[], doctors: [] as Array<{ id: number; name: string; disable: number }>,
|
||||
can_filter_department: true
|
||||
},
|
||||
summary: {
|
||||
appointment_total: 0, interview_count: 0, order_count: 0, deal_amount: 0,
|
||||
avg_order_amount: null as number | null, appointment_completion_rate: null as number | null,
|
||||
receive_conversion_rate: null as number | null, missed_count: 0, cancelled_count: 0
|
||||
},
|
||||
rankings: { amounts: [] as any[], conversion: [] as any[] },
|
||||
funnel: [] as Array<{ key: string; label: string; value: number }>,
|
||||
trend: { start_date: '', end_date: '', dates: [] as string[], labels: [] as string[], amounts: [] as number[] },
|
||||
alerts: [] as any[],
|
||||
alert_threshold: 15,
|
||||
rows: [] as any[]
|
||||
})
|
||||
|
||||
const dashboard = reactive(emptyDashboard())
|
||||
const loading = ref(false)
|
||||
const viewMode = ref<'overview' | 'detail'>('overview')
|
||||
const showZeroRows = ref(false)
|
||||
const query = reactive<FirstVisitDoctorDashboardParams>({
|
||||
time_type: 'month',
|
||||
active_only: 1,
|
||||
alert_threshold: 15
|
||||
})
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' }
|
||||
]
|
||||
const doctorStatusOptions = [
|
||||
{ label: '仅活跃医生', value: 1 },
|
||||
{ label: '全部医生', value: 0 }
|
||||
]
|
||||
const thresholdOptions = [10, 15, 20, 30]
|
||||
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
|
||||
|
||||
const maxAmount = computed(() => Math.max(0, ...dashboard.rankings.amounts.map((item: any) => Number(item.value) || 0)))
|
||||
const businessRows = computed(() => dashboard.rows.filter((row: any) => hasBusinessData(row)))
|
||||
const zeroDataRows = computed(() => dashboard.rows.filter((row: any) => !hasBusinessData(row)))
|
||||
const visibleDetailRows = computed(() => {
|
||||
if (query.doctor_id || showZeroRows.value) return dashboard.rows
|
||||
return businessRows.value
|
||||
})
|
||||
const funnelLoss = computed(() => {
|
||||
const stages = dashboard.funnel
|
||||
let result = { label: '暂无可比阶段', value: 0 }
|
||||
let maxLoss = -1
|
||||
for (let index = 0; index < stages.length - 1; index++) {
|
||||
const loss = Math.max(0, Number(stages[index].value) - Number(stages[index + 1].value))
|
||||
if (loss > maxLoss) {
|
||||
maxLoss = loss
|
||||
result = { label: `${stages[index].label} → ${stages[index + 1].label}`, value: loss }
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
const trendChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
grid: { left: 20, right: 20, top: 18, bottom: 18, containLabel: true },
|
||||
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
|
||||
xAxis: {
|
||||
type: 'category', boundaryGap: false, data: dashboard.trend.labels,
|
||||
axisLine: { lineStyle: { color: '#dfe5eb' } }, axisTick: { show: false },
|
||||
axisLabel: { color: '#7e8a9b', fontSize: 10, interval: 4 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', splitNumber: 4,
|
||||
axisLabel: { color: '#8793a2', fontSize: 10, formatter: (value: number) => compactMoney(value) },
|
||||
splitLine: { lineStyle: { color: '#eef2f5' } }
|
||||
},
|
||||
series: [{
|
||||
name: '成交金额', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5,
|
||||
data: dashboard.trend.amounts,
|
||||
lineStyle: { color: '#139a8c', width: 3 },
|
||||
itemStyle: { color: '#ffffff', borderColor: '#139a8c', borderWidth: 2 },
|
||||
areaStyle: { color: 'rgba(19,154,140,.08)' }
|
||||
}]
|
||||
}))
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: FirstVisitDoctorDashboardParams = {
|
||||
time_type: query.time_type,
|
||||
active_only: query.active_only ?? 1,
|
||||
alert_threshold: Number(query.alert_threshold || 15)
|
||||
}
|
||||
if (query.dept_id) params.dept_id = Number(query.dept_id)
|
||||
if (query.doctor_id) params.doctor_id = Number(query.doctor_id)
|
||||
const result: any = await firstVisitDoctorDashboardOverview(params)
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '医生看板加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDepartmentChange() {
|
||||
delete query.doctor_id
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function handleActiveChange() {
|
||||
delete query.doctor_id
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown) {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatDetailMoney(value: unknown) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function nullableMoney(value: unknown) {
|
||||
return value === null || value === undefined ? '—' : formatMoney(value)
|
||||
}
|
||||
|
||||
function formatPercent(value: unknown) {
|
||||
return value === null || value === undefined ? '—' : `${Number(value).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function compactMoney(value: number) {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
function barWidth(value: unknown, max: number) {
|
||||
if (max <= 0) return '0%'
|
||||
return `${Math.max(3, Math.min(100, Number(value || 0) / max * 100))}%`
|
||||
}
|
||||
|
||||
function percentageWidth(value: unknown) {
|
||||
return `${Math.max(0, Math.min(100, Number(value) || 0))}%`
|
||||
}
|
||||
|
||||
function conversionClass(value: unknown) {
|
||||
if (value === null || value === undefined) return 'conversion-rate is-neutral'
|
||||
return Number(value) < Number(query.alert_threshold || 15) ? 'conversion-rate is-low' : 'conversion-rate is-good'
|
||||
}
|
||||
|
||||
function hasBusinessData(row: any) {
|
||||
return Number(row.appointment_total || 0) > 0
|
||||
|| Number(row.interview_count || 0) > 0
|
||||
|| Number(row.order_count || 0) > 0
|
||||
|| Number(row.deal_amount || 0) > 0
|
||||
|| Number(row.appointment_missed || 0) > 0
|
||||
|| Number(row.appointment_cancelled || 0) > 0
|
||||
}
|
||||
|
||||
function isLowConversion(row: any) {
|
||||
return Number(row.interview_count || 0) > 0
|
||||
&& Number(row.receive_conversion_rate || 0) < Number(query.alert_threshold || 15)
|
||||
}
|
||||
|
||||
function detailRowClassName({ row }: { row: any }) {
|
||||
if (isLowConversion(row)) return 'is-conversion-warning'
|
||||
if (!hasBusinessData(row)) return 'is-zero-data'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function openDoctorDetail(row: any) {
|
||||
query.doctor_id = Number(row.doctor_id)
|
||||
viewMode.value = 'overview'
|
||||
showZeroRows.value = false
|
||||
await loadDashboard()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
return `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function exportRows() {
|
||||
if (!dashboard.rows.length) {
|
||||
ElMessage.warning('当前范围暂无可导出的医生数据')
|
||||
return
|
||||
}
|
||||
const headers = ['医生', '状态', '挂号', '面诊', '接诊', '接诊率', '成交金额', '过号', '取消']
|
||||
const lines = dashboard.rows.map((row: any) => [
|
||||
row.doctor_name, row.status === 'disabled' ? '停用' : '活跃', row.appointment_total,
|
||||
row.interview_count, row.order_count, formatPercent(row.receive_conversion_rate), row.deal_amount,
|
||||
row.appointment_missed, row.appointment_cancelled
|
||||
].map(csvCell).join(','))
|
||||
const blob = new Blob([`\uFEFF${headers.map(csvCell).join(',')}\n${lines.join('\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `医生看板_${dashboard.meta.start_date}_${dashboard.meta.end_date}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.doctor-dashboard {
|
||||
--ink: #17243a;
|
||||
--muted: #768497;
|
||||
--line: #e2e8ee;
|
||||
--canvas: #f4f6f8;
|
||||
--teal: #139a8c;
|
||||
--blue: #3d78e7;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.metric-card,
|
||||
.panel { border: 1px solid var(--line); background: #fff; }
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-height: 76px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
.heading-copy, .heading-actions, .filter-item, .threshold-control { display: flex; align-items: center; }
|
||||
.heading-copy { gap: 12px; }
|
||||
.heading-mark {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
background: var(--teal);
|
||||
box-shadow: 0 8px 20px rgba(19, 154, 140, .16);
|
||||
font-size: 20px;
|
||||
}
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: 20px; line-height: 1.3; }
|
||||
h2 { font-size: 15px; line-height: 1.4; }
|
||||
.heading-copy p, .panel-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.heading-actions { gap: 10px; color: var(--muted); font-size: 12px; }
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid #cce8e3;
|
||||
border-radius: 7px;
|
||||
color: #117f75;
|
||||
background: #f2faf8;
|
||||
}
|
||||
|
||||
.filter-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-top: 14px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.filter-item { gap: 8px; color: #68778a; font-size: 12px; }
|
||||
.dept-select { width: 190px; }
|
||||
.doctor-select { width: 160px; }
|
||||
.view-switch { display: flex; margin-left: auto; padding-left: 12px; border-left: 1px solid #e6ebef; }
|
||||
.view-switch button {
|
||||
min-width: 82px;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #657488;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.view-switch button.active { border-bottom-color: var(--teal); color: #0e8277; font-weight: 700; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 105px;
|
||||
padding: 16px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.metric-icon {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.metric-card--teal .metric-icon { color: #0b887c; background: #e8f7f4; }
|
||||
.metric-card--green .metric-icon { color: #37a465; background: #eef8f1; }
|
||||
.metric-card--indigo .metric-icon { color: #556dde; background: #eef0fd; }
|
||||
.metric-card--blue .metric-icon { color: #3976e6; background: #edf3ff; }
|
||||
.metric-card--cyan .metric-icon { color: #138da1; background: #eaf7f8; }
|
||||
.metric-card span { display: block; color: #748296; font-size: 12px; }
|
||||
.metric-card strong { display: block; margin: 4px 0 3px; font-size: 24px; line-height: 1.1; }
|
||||
.metric-card small { color: #8995a4; font-size: 10px; }
|
||||
|
||||
.two-column-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel { margin-top: 14px; border-radius: 12px; overflow: hidden; }
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px 11px;
|
||||
}
|
||||
.panel-heading > span { color: #8a96a5; font-size: 11px; }
|
||||
.ranking-panel { min-height: 284px; }
|
||||
.bar-list { padding: 0 16px 15px; }
|
||||
.bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(80px, .35fr) minmax(140px, 1fr) 110px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 34px;
|
||||
}
|
||||
.bar-row > b { color: #8793a2; text-align: center; font-size: 10px; }
|
||||
.bar-row > span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-row > strong { text-align: right; font-size: 12px; }
|
||||
.bar-track { height: 9px; overflow: hidden; border-radius: 6px; background: #edf1f4; }
|
||||
.bar-track i { display: block; height: 100%; border-radius: inherit; }
|
||||
.bar-track .is-blue { background: var(--blue); }
|
||||
.bar-track .is-teal { background: var(--teal); }
|
||||
|
||||
.analytics-grid .panel { min-height: 300px; }
|
||||
.funnel-wrap { display: flex; align-items: center; flex-direction: column; gap: 5px; padding: 12px 48px 7px; }
|
||||
.funnel-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
clip-path: polygon(4% 0, 96% 0, 88% 100%, 12% 100%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.funnel-stage strong { font-size: 14px; }
|
||||
.stage-1 { background: #258bd4; }
|
||||
.stage-2 { background: #20a290; }
|
||||
.stage-3 { background: #ed913f; }
|
||||
.stage-4 { background: #45a96d; }
|
||||
.funnel-insight { margin: 8px 16px 16px; padding: 8px 10px; border-radius: 7px; color: #617085; background: #f4f7f8; font-size: 11px; }
|
||||
.funnel-insight strong { color: #d4772c; }
|
||||
.trend-chart { width: 100%; height: 238px; }
|
||||
|
||||
.alert-panel { border-color: #dfe8e7; }
|
||||
.alert-panel.has-alerts { border-color: #efc4c4; }
|
||||
.alert-heading h2 { display: flex; align-items: center; gap: 6px; }
|
||||
.alert-heading h2 .el-icon { color: #ee5b5b; }
|
||||
.threshold-control { gap: 8px; color: #768497; font-size: 11px; }
|
||||
.threshold-select { width: 88px; }
|
||||
.alert-list { padding: 0 14px 14px; }
|
||||
.alert-row {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(120px, .45fr) 82px 135px minmax(220px, 1fr) 65px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 54px;
|
||||
margin-top: 8px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #f0dddd;
|
||||
border-radius: 9px;
|
||||
background: #fffafa;
|
||||
}
|
||||
.alert-icon { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 50%; color: #fff; background: #ef5a5a; font-weight: 700; }
|
||||
.alert-doctor strong, .alert-doctor small { display: block; }
|
||||
.alert-doctor strong { font-size: 12px; }
|
||||
.alert-doctor small { margin-top: 2px; color: #8d98a6; font-size: 10px; }
|
||||
.severity { padding: 4px 7px; border-radius: 5px; text-align: center; font-size: 10px; }
|
||||
.severity.is-high { color: #d84444; background: #ffe9e9; }
|
||||
.severity.is-medium { color: #c77726; background: #fff1df; }
|
||||
.alert-data { color: #67768a; font-size: 11px; }
|
||||
.alert-row p { color: #7b8797; font-size: 11px; }
|
||||
.alert-rate { color: #e34949; text-align: right; font-size: 15px; }
|
||||
.alert-empty { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 94px; color: #4f8f77; font-size: 12px; }
|
||||
.alert-empty .el-icon { font-size: 20px; }
|
||||
|
||||
.detail-panel { min-height: 360px; }
|
||||
.detail-table { border-top: 1px solid #edf1f4; --el-table-header-bg-color: #f7f9fb; }
|
||||
.detail-table :deep(th.el-table__cell) { height: 43px; color: #68778a; font-weight: 500; }
|
||||
.detail-table :deep(td.el-table__cell) { height: 47px; }
|
||||
.detail-table :deep(.is-conversion-warning > td.el-table__cell) { background: #fff0f0 !important; }
|
||||
.detail-table :deep(.is-zero-data > td.el-table__cell) { color: #98a2af; background: #fafbfc !important; }
|
||||
.money-text { color: #246bd3; }
|
||||
.conversion-rate.is-low { color: #e04d4d; font-weight: 700; }
|
||||
.conversion-rate.is-good { color: #168f72; }
|
||||
.conversion-rate.is-neutral { color: #919cab; }
|
||||
.warning-mark { margin-right: 3px; color: #ec4e4e; font-size: 10px; }
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 11px;
|
||||
color: #16895f;
|
||||
background: #eaf8ef;
|
||||
font-size: 11px;
|
||||
}
|
||||
.status-pill.disabled { color: #9a6570; background: #f4ecee; }
|
||||
.detail-action { font-size: 12px; }
|
||||
.zero-doctor-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 10px 16px 15px;
|
||||
padding: 4px 9px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
color: #6e7d90;
|
||||
background: #eef3f6;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.zero-doctor-toggle:hover { color: #0e8277; background: #e7f4f1; }
|
||||
.data-note { display: flex; align-items: center; gap: 6px; padding: 11px 2px 2px; color: #8b97a6; font-size: 11px; }
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.view-switch { margin-left: 0; }
|
||||
.metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.doctor-dashboard { padding: 10px; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; }
|
||||
.heading-actions { flex-wrap: wrap; }
|
||||
.update-time { display: none; }
|
||||
.metric-grid, .two-column-grid { grid-template-columns: 1fr; }
|
||||
.metric-card { min-height: 92px; }
|
||||
.filter-item { width: 100%; justify-content: space-between; }
|
||||
.dept-select, .doctor-select { width: calc(100% - 78px); }
|
||||
.filter-item--time, .filter-item--status { justify-content: flex-start; }
|
||||
.view-switch { width: 100%; justify-content: flex-end; border-left: 0; }
|
||||
.alert-row { grid-template-columns: 30px 1fr 80px 65px; padding: 10px; }
|
||||
.alert-data, .alert-row p { grid-column: 2 / -1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,684 @@
|
||||
<template>
|
||||
<prescription-order-detail-drawer
|
||||
ref="detailDrawerRef"
|
||||
readonly
|
||||
append-to-body
|
||||
:detail-loader="myPatientOrderDetail"
|
||||
:load-related-data="false"
|
||||
>
|
||||
<template #header-actions="{ detail }">
|
||||
<el-dropdown
|
||||
v-if="myPatientOrderActions(detail).length"
|
||||
trigger="click"
|
||||
@command="(command) => openAction(detail, command as MyPatientOrderAction)"
|
||||
>
|
||||
<el-button type="primary" size="small" plain>
|
||||
订单操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="action in myPatientOrderActions(detail)"
|
||||
:key="action.key"
|
||||
:command="action.key"
|
||||
:class="{ 'danger-menu-item': action.danger }"
|
||||
>
|
||||
{{ action.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</prescription-order-detail-drawer>
|
||||
|
||||
<el-dialog v-model="auditVisible" :title="auditTitle" width="480px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="通过时审核意见可不填;驳回时必须填写明确原因。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-input
|
||||
v-model="auditRemark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入审核意见"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="auditVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAudit('approve')">同意通过</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="submitAudit('reject')">驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="trackVisible" title="修改快递单号" width="440px" append-to-body destroy-on-close>
|
||||
<el-form label-width="92px" @submit.prevent="submitTrack">
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="trackForm.express_company" class="w-full">
|
||||
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号" required>
|
||||
<el-input v-model="trackForm.tracking_number" maxlength="80" clearable placeholder="请输入快递单号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="trackVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitTrack">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="shipVisible" title="确认发货" width="460px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="确认后订单将进入“已发货”,请先核对发货方式和快递信息。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="92px" @submit.prevent="submitShip">
|
||||
<el-form-item label="发货方式">
|
||||
<el-tag :type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'" effect="plain">
|
||||
{{ shipForm.ship_mode === 'direct' ? '洛阳药房' : '甘草药房' }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="shipForm.express_company" class="w-full">
|
||||
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号" required>
|
||||
<el-input v-model="shipForm.tracking_number" maxlength="80" clearable placeholder="请输入快递单号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="shipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑订单" width="680px" append-to-body destroy-on-close>
|
||||
<el-form v-loading="editLoading" label-width="104px" class="order-edit-form">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="收货人" required>
|
||||
<el-input v-model="editForm.recipient_name" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="收货手机" required>
|
||||
<el-input v-model="editForm.recipient_phone" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="费用类别" required>
|
||||
<el-select v-model="editForm.fee_type" class="w-full">
|
||||
<el-option v-for="item in feeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单金额" required>
|
||||
<el-input-number v-model="editForm.amount" :min="0" :precision="2" :step="10" class="w-full" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="收货地址" required>
|
||||
<el-input v-model="editForm.shipping_address" maxlength="500" />
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="editForm.express_company" class="w-full">
|
||||
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号">
|
||||
<el-input v-model="editForm.tracking_number" maxlength="80" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="医助备注">
|
||||
<el-input v-model="editForm.remark_assistant" type="textarea" :rows="2" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="药房备注">
|
||||
<el-input v-model="editForm.remark_extra" type="textarea" :rows="2" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
title="保存后会沿用原订单编辑规则;非已发货订单的支付审核将重新变为待审核。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="editLoading" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="payVisible" title="补齐支付单" width="520px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
:title="`订单金额 ¥${money(actionRow.amount)},已关联支付 ¥${money(actionRow.linked_pay_paid_total)}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="108px">
|
||||
<el-form-item label="支付单类型">
|
||||
<el-select v-model="payForm.order_type" class="w-full">
|
||||
<el-option v-for="item in payTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建方式">
|
||||
<el-radio-group v-model="payForm.pay_create_type">
|
||||
<el-radio value="fubei">付呗支付</el-radio>
|
||||
<el-radio value="express_cod">快递代收</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="补齐金额" required>
|
||||
<el-input-number v-model="payForm.pay_amount" :min="0" :precision="2" :step="10" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="支付备注">
|
||||
<el-input v-model="payForm.pay_remark" type="textarea" :rows="3" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="完单申请">
|
||||
<el-switch v-model="payForm.completion_request" :active-value="1" :inactive-value="0" />
|
||||
<span class="form-tip">同时提交完单申请</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="payVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitPayOrder">确认新增</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="completeVisible" title="完成订单" width="480px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="请选择真实履约结果。退款必须使用单独的“退款”操作。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="结案状态" required>
|
||||
<el-select v-model="completeStatus" class="w-full">
|
||||
<el-option v-for="item in completeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="completeVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitComplete">确认完成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="refundVisible" title="订单退款" width="500px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="退款会同步更新关联支付单和订单金额;退款金额留空时按系统计算的最大可退金额处理。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="退款原因" required>
|
||||
<el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="退款金额">
|
||||
<el-input-number
|
||||
v-model="refundForm.refund_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="10"
|
||||
class="w-full"
|
||||
placeholder="留空则退最大可退金额"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="refundVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="submitRefund">确认退款</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import feedback from '@/utils/feedback'
|
||||
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
|
||||
import {
|
||||
myPatientOrderAddPayOrder,
|
||||
myPatientOrderAuditPayment,
|
||||
myPatientOrderAuditPrescription,
|
||||
myPatientOrderComplete,
|
||||
myPatientOrderDdcode,
|
||||
myPatientOrderDetail,
|
||||
myPatientOrderEdit,
|
||||
myPatientOrderRefund,
|
||||
myPatientOrderRevokePayAudit,
|
||||
myPatientOrderRevokeRxAudit,
|
||||
myPatientOrderShip,
|
||||
myPatientOrderUploadToPharmacy,
|
||||
myPatientOrderWithdraw
|
||||
} from '@/api/first_visit'
|
||||
import { myPatientOrderActions, type MyPatientOrderAction } from './order-actions'
|
||||
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||
const actionRow = ref<Record<string, any>>({})
|
||||
const submitting = ref(false)
|
||||
|
||||
const expressOptions = [
|
||||
{ label: '自动识别', value: 'auto' },
|
||||
{ label: '顺丰速运', value: 'sf' },
|
||||
{ label: '京东快递', value: 'jd' },
|
||||
{ label: '极兔速递', value: 'jt' }
|
||||
]
|
||||
const feeTypeOptions = [
|
||||
{ label: '挂号', value: 1 },
|
||||
{ label: '问诊', value: 2 },
|
||||
{ label: '药品', value: 3 },
|
||||
{ label: '首付', value: 4 },
|
||||
{ label: '尾款', value: 5 },
|
||||
{ label: '其他', value: 6 },
|
||||
{ label: '全部', value: 7 },
|
||||
{ label: '代收', value: 8 }
|
||||
]
|
||||
const payTypeOptions = [
|
||||
{ label: '药品', value: 3 },
|
||||
{ label: '尾款', value: 5 },
|
||||
{ label: '其他', value: 6 }
|
||||
]
|
||||
const completeOptions = [
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '进行中', value: 7 },
|
||||
{ label: '暂不制药', value: 8 },
|
||||
{ label: '拒收', value: 9 },
|
||||
{ label: '保留药方', value: 11 },
|
||||
{ label: '制药缓发', value: 12 }
|
||||
]
|
||||
|
||||
const auditVisible = ref(false)
|
||||
const auditKind = ref<'prescription' | 'payment'>('prescription')
|
||||
const auditRemark = ref('')
|
||||
const auditTitle = computed(() => auditKind.value === 'prescription' ? '处方业务审核' : '关联支付单审核')
|
||||
|
||||
const trackVisible = ref(false)
|
||||
const trackForm = reactive({ express_company: 'auto', tracking_number: '' })
|
||||
|
||||
const shipVisible = ref(false)
|
||||
const shipForm = reactive<{ ship_mode: 'gancao' | 'direct'; express_company: string; tracking_number: string }>({
|
||||
ship_mode: 'gancao',
|
||||
express_company: 'auto',
|
||||
tracking_number: ''
|
||||
})
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editSource = ref<Record<string, any>>({})
|
||||
const editForm = reactive({
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
shipping_address: '',
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
express_company: 'auto',
|
||||
tracking_number: '',
|
||||
remark_assistant: '',
|
||||
remark_extra: ''
|
||||
})
|
||||
|
||||
const payVisible = ref(false)
|
||||
const payForm = reactive<{
|
||||
order_type: number
|
||||
pay_amount: number
|
||||
pay_remark: string
|
||||
completion_request: number
|
||||
pay_create_type: 'fubei' | 'express_cod'
|
||||
}>({ order_type: 3, pay_amount: 0, pay_remark: '', completion_request: 0, pay_create_type: 'fubei' })
|
||||
|
||||
const completeVisible = ref(false)
|
||||
const completeStatus = ref(3)
|
||||
|
||||
const refundVisible = ref(false)
|
||||
const refundForm = reactive<{ reason: string; refund_amount: number | undefined }>({
|
||||
reason: '',
|
||||
refund_amount: undefined
|
||||
})
|
||||
|
||||
function money(value: unknown) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function normalizeShipMode(value: unknown): 'gancao' | 'direct' {
|
||||
return String(value || '').toLowerCase() === 'direct' ? 'direct' : 'gancao'
|
||||
}
|
||||
|
||||
function openDetail(row: Record<string, any>) {
|
||||
detailDrawerRef.value?.open(Number(row.id))
|
||||
}
|
||||
|
||||
async function fetchDetail(id: number) {
|
||||
const response: any = await myPatientOrderDetail({ id })
|
||||
return response?.data ?? response ?? null
|
||||
}
|
||||
|
||||
async function openEdit(row: Record<string, any>) {
|
||||
actionRow.value = row
|
||||
editVisible.value = true
|
||||
editLoading.value = true
|
||||
try {
|
||||
const detail = await fetchDetail(Number(row.id))
|
||||
if (!detail) throw new Error('订单详情加载失败')
|
||||
editSource.value = detail
|
||||
editForm.recipient_name = String(detail.recipient_name || '')
|
||||
editForm.recipient_phone = String(detail.recipient_phone || '')
|
||||
editForm.shipping_address = String(detail.shipping_address || '')
|
||||
editForm.fee_type = Number(detail.fee_type || 3)
|
||||
editForm.amount = Number(detail.amount || 0)
|
||||
editForm.express_company = String(detail.express_company || 'auto') || 'auto'
|
||||
editForm.tracking_number = String(detail.tracking_number || '')
|
||||
editForm.remark_assistant = String(detail.remark_assistant || '')
|
||||
editForm.remark_extra = String(detail.remark_extra || '')
|
||||
} catch (error: any) {
|
||||
feedback.msgError(error?.message || '订单详情加载失败')
|
||||
editVisible.value = false
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAction(row: Record<string, any>, action: MyPatientOrderAction) {
|
||||
actionRow.value = row
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
void openEdit(row)
|
||||
break
|
||||
case 'audit_prescription':
|
||||
case 'audit_payment':
|
||||
auditKind.value = action === 'audit_prescription' ? 'prescription' : 'payment'
|
||||
auditRemark.value = ''
|
||||
auditVisible.value = true
|
||||
break
|
||||
case 'revoke_rx_audit':
|
||||
void confirmRevokeAudit('prescription')
|
||||
break
|
||||
case 'revoke_pay_audit':
|
||||
void confirmRevokeAudit('payment')
|
||||
break
|
||||
case 'ddcode':
|
||||
trackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||
trackForm.tracking_number = String(row.tracking_number || '')
|
||||
trackVisible.value = true
|
||||
break
|
||||
case 'ship':
|
||||
shipForm.ship_mode = normalizeShipMode(row.ship_mode)
|
||||
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||
shipForm.tracking_number = String(row.tracking_number || '')
|
||||
shipVisible.value = true
|
||||
break
|
||||
case 'add_pay_order': {
|
||||
const diff = Math.max(0, Number(row.amount || 0) - Number(row.linked_pay_paid_total || 0))
|
||||
payForm.order_type = 3
|
||||
payForm.pay_amount = Number(diff.toFixed(2))
|
||||
payForm.pay_remark = ''
|
||||
payForm.completion_request = 0
|
||||
payForm.pay_create_type = 'fubei'
|
||||
payVisible.value = true
|
||||
break
|
||||
}
|
||||
case 'complete':
|
||||
completeStatus.value = 3
|
||||
completeVisible.value = true
|
||||
break
|
||||
case 'refund':
|
||||
refundForm.reason = ''
|
||||
refundForm.refund_amount = undefined
|
||||
refundVisible.value = true
|
||||
break
|
||||
case 'withdraw':
|
||||
void confirmWithdraw()
|
||||
break
|
||||
case 'upload_pharmacy':
|
||||
void confirmUploadPharmacy()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function changed(message: string) {
|
||||
feedback.msgSuccess(message)
|
||||
emit('changed')
|
||||
await detailDrawerRef.value?.refreshIfCurrent(actionRow.value.id)
|
||||
}
|
||||
|
||||
async function submitAudit(action: 'approve' | 'reject') {
|
||||
if (action === 'reject' && !auditRemark.value.trim()) {
|
||||
feedback.msgError('驳回时必须填写审核意见')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const params = { id: Number(actionRow.value.id), action, remark: auditRemark.value.trim() }
|
||||
if (auditKind.value === 'prescription') await myPatientOrderAuditPrescription(params)
|
||||
else await myPatientOrderAuditPayment(params)
|
||||
auditVisible.value = false
|
||||
await changed(action === 'approve' ? '审核通过成功' : '订单已驳回')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRevokeAudit(kind: 'prescription' | 'payment') {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认撤回${kind === 'prescription' ? '处方' : '支付单'}审核并恢复为待审核吗?`,
|
||||
'撤回审核',
|
||||
{ type: 'warning', confirmButtonText: '确认撤回', cancelButtonText: '取消' }
|
||||
)
|
||||
if (kind === 'prescription') await myPatientOrderRevokeRxAudit({ id: Number(actionRow.value.id) })
|
||||
else await myPatientOrderRevokePayAudit({ id: Number(actionRow.value.id) })
|
||||
await changed('审核已撤回')
|
||||
} catch {
|
||||
// 取消确认或请求拦截器已处理
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTrack() {
|
||||
if (!trackForm.tracking_number.trim()) {
|
||||
feedback.msgError('请填写快递单号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderDdcode({
|
||||
id: Number(actionRow.value.id),
|
||||
express_company: trackForm.express_company || 'auto',
|
||||
tracking_number: trackForm.tracking_number.trim()
|
||||
})
|
||||
trackVisible.value = false
|
||||
await changed('快递单号已保存')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitShip() {
|
||||
if (!shipForm.tracking_number.trim()) {
|
||||
feedback.msgError('请填写快递单号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderShip({
|
||||
id: Number(actionRow.value.id),
|
||||
ship_mode: shipForm.ship_mode,
|
||||
express_company: shipForm.express_company || 'auto',
|
||||
tracking_number: shipForm.tracking_number.trim()
|
||||
})
|
||||
shipVisible.value = false
|
||||
await changed('确认发货成功')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editForm.recipient_name.trim() || !editForm.recipient_phone.trim() || !editForm.shipping_address.trim()) {
|
||||
feedback.msgError('请完整填写收货人、收货手机和收货地址')
|
||||
return
|
||||
}
|
||||
if (Number(editForm.amount) < 0) {
|
||||
feedback.msgError('订单金额不能为负数')
|
||||
return
|
||||
}
|
||||
const source = editSource.value
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderEdit({
|
||||
id: Number(actionRow.value.id),
|
||||
recipient_name: editForm.recipient_name.trim(),
|
||||
recipient_phone: editForm.recipient_phone.trim(),
|
||||
shipping_province: source.shipping_province || '',
|
||||
shipping_city: source.shipping_city || '',
|
||||
shipping_district: source.shipping_district || '',
|
||||
shipping_address: editForm.shipping_address.trim(),
|
||||
is_follow_up: Number(source.is_follow_up || 0),
|
||||
medication_days: source.medication_days,
|
||||
dose_unit: source.dose_unit || '剂',
|
||||
dose_count: Number(source.dose_count || 1),
|
||||
prev_staff: source.prev_staff || '',
|
||||
service_channel: source.service_channel || '',
|
||||
service_package: source.service_package || '',
|
||||
tracking_number: editForm.tracking_number.trim(),
|
||||
express_company: editForm.express_company || 'auto',
|
||||
fee_type: Number(editForm.fee_type),
|
||||
amount: Number(editForm.amount),
|
||||
remark_extra: editForm.remark_extra.trim(),
|
||||
remark_assistant: editForm.remark_assistant.trim(),
|
||||
pay_order_ids: Array.isArray(source.pay_order_ids) ? source.pay_order_ids : []
|
||||
})
|
||||
editVisible.value = false
|
||||
await changed('订单保存成功')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPayOrder() {
|
||||
if (!(Number(payForm.pay_amount) > 0)) {
|
||||
feedback.msgError('补齐金额必须大于 0')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderAddPayOrder({
|
||||
id: Number(actionRow.value.id),
|
||||
order_type: Number(payForm.order_type),
|
||||
pay_amount: Number(payForm.pay_amount),
|
||||
pay_remark: payForm.pay_remark.trim(),
|
||||
completion_request: Number(payForm.completion_request),
|
||||
pay_create_type: payForm.pay_create_type
|
||||
})
|
||||
payVisible.value = false
|
||||
await changed('支付单已新增,等待支付审核')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComplete() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderComplete({ id: Number(actionRow.value.id), fulfillment_status: Number(completeStatus.value) })
|
||||
completeVisible.value = false
|
||||
await changed('订单状态已更新')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRefund() {
|
||||
if (!refundForm.reason.trim()) {
|
||||
feedback.msgError('请填写退款原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderRefund({
|
||||
id: Number(actionRow.value.id),
|
||||
reason: refundForm.reason.trim(),
|
||||
refund_amount: refundForm.refund_amount === undefined ? undefined : Number(refundForm.refund_amount)
|
||||
})
|
||||
refundVisible.value = false
|
||||
await changed('退款成功')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmWithdraw() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认撤回该订单吗?撤回后当前订单将结束。', '撤回订单', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认撤回',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await myPatientOrderWithdraw({ id: Number(actionRow.value.id) })
|
||||
await changed('订单已撤回')
|
||||
} catch {
|
||||
// 取消确认或请求拦截器已处理
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUploadPharmacy() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将该订单上传到订单设定的药房吗?', '上传药房', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认上传',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await myPatientOrderUploadToPharmacy({ id: Number(actionRow.value.id) })
|
||||
await changed('药方上传成功')
|
||||
} catch {
|
||||
// 取消确认或请求拦截器已处理
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openDetail, openAction })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 14px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 10px;
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:global(.danger-menu-item) {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<section class="embedded-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div>
|
||||
<h2>订单管理</h2>
|
||||
<p>展示当前患者范围内的处方业务订单,订单创建人不参与数据归属判断</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
|
||||
<el-button :icon="Refresh" :loading="pager.loading" @click="refreshPanel">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-panel">
|
||||
<el-input
|
||||
v-model="formData.keyword"
|
||||
class="keyword-input"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
placeholder="订单号 / 患者 / 手机号 / 处方ID / 诊单ID"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<el-select v-model="formData.prescription_audit_status" clearable placeholder="处方审核" @change="handleSearch">
|
||||
<el-option v-for="item in auditOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-select v-model="formData.payment_slip_audit_status" clearable placeholder="支付单审核" @change="handleSearch">
|
||||
<el-option v-for="item in auditOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-select v-model="formData.fulfillment_status" clearable placeholder="履约状态" @change="handleSearch">
|
||||
<el-option v-for="item in fulfillmentOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
class="date-range"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="创建开始"
|
||||
end-placeholder="创建结束"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>订单数量</span>
|
||||
<strong>{{ summary.orders }}</strong>
|
||||
<small>笔</small>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>订单金额</span>
|
||||
<strong>¥{{ money(summary.amount) }}</strong>
|
||||
<small>当前筛选</small>
|
||||
</div>
|
||||
<div class="metric-card metric-warning">
|
||||
<span>待审核</span>
|
||||
<strong>{{ summary.pending }}</strong>
|
||||
<small>任一审核待处理</small>
|
||||
</div>
|
||||
<div class="metric-card metric-success">
|
||||
<span>已完成 / 签收</span>
|
||||
<strong>{{ summary.completed }}</strong>
|
||||
<small>笔</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
class="embedded-table"
|
||||
:row-class-name="orderRowClassName"
|
||||
>
|
||||
<el-table-column label="订单" min-width="174" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="primary-cell">
|
||||
<strong>{{ row.order_no || '—' }}</strong>
|
||||
<span>#{{ row.id }} · {{ row.fee_type_text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="患者" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="primary-cell">
|
||||
<strong>{{ row.patient_name || row.recipient_name || '—' }}</strong>
|
||||
<span>{{ row.patient_phone_masked || row.recipient_phone_masked || '无手机号' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处方 / 诊单" min-width="118">
|
||||
<template #default="{ row }">
|
||||
<div class="id-stack">
|
||||
<span>处方 #{{ row.prescription_id || '—' }}</span>
|
||||
<span>诊单 #{{ row.diagnosis_id || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="108" align="right">
|
||||
<template #default="{ row }"><strong class="amount">¥{{ money(row.amount) }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处方审核" width="104" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="auditTagType(row.prescription_audit_status)" effect="light">
|
||||
{{ row.prescription_audit_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付单审核" width="112" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="auditTagType(row.payment_slip_audit_status)" effect="light">
|
||||
{{ row.payment_slip_audit_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="履约状态" width="112" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="fulfillmentTagType(row.fulfillment_status)" effect="light">
|
||||
{{ row.fulfillment_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联支付单" width="100" align="center">
|
||||
<template #default="{ row }">{{ Number(row.linked_pay_order_count) > 0 ? `${row.linked_pay_order_count} 笔` : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assistant_name" label="归属助理" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="doctor_name" label="开方人" min-width="90" show-overflow-tooltip />
|
||||
<el-table-column prop="creator_name" label="创建人" min-width="90" show-overflow-tooltip />
|
||||
<el-table-column prop="create_time_text" label="创建时间" min-width="145" />
|
||||
<el-table-column label="操作" width="190" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="order-actions">
|
||||
<el-button type="primary" link @click="emit('openDiagnosis', row)">诊单</el-button>
|
||||
<el-button
|
||||
v-if="canViewMyPatientOrderDetail()"
|
||||
type="primary"
|
||||
link
|
||||
@click="orderActionHostRef?.openDetail(row)"
|
||||
>详情</el-button>
|
||||
<el-dropdown
|
||||
v-if="myPatientOrderActions(row).length"
|
||||
trigger="click"
|
||||
@command="(command) => openOrderAction(row, command as MyPatientOrderAction)"
|
||||
>
|
||||
<el-button type="primary" link>
|
||||
操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="action in myPatientOrderActions(row)"
|
||||
:key="action.key"
|
||||
:command="action.key"
|
||||
:class="{ 'danger-menu-item': action.danger }"
|
||||
>
|
||||
{{ action.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前范围内暂无订单" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
|
||||
<order-action-host ref="orderActionHostRef" @changed="getLists" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ArrowDown, Lock, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { myPatientOrderLists } from '@/api/first_visit'
|
||||
import OrderActionHost from './OrderActionHost.vue'
|
||||
import {
|
||||
canViewMyPatientOrderDetail,
|
||||
myPatientOrderActions,
|
||||
type MyPatientOrderAction
|
||||
} from './order-actions'
|
||||
|
||||
type SelectValue = '' | number
|
||||
|
||||
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
|
||||
const orderActionHostRef = ref<InstanceType<typeof OrderActionHost>>()
|
||||
const dateRange = ref<string[]>([])
|
||||
const formData = reactive({
|
||||
keyword: '',
|
||||
prescription_audit_status: '' as SelectValue,
|
||||
payment_slip_audit_status: '' as SelectValue,
|
||||
fulfillment_status: '' as SelectValue,
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const auditOptions = [
|
||||
{ label: '待审核', value: 0 },
|
||||
{ label: '已通过', value: 1 },
|
||||
{ label: '已驳回', value: 2 }
|
||||
]
|
||||
const fulfillmentOptions = [
|
||||
{ label: '待双审通过', value: 1 },
|
||||
{ label: '待发货', value: 2 },
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '已取消', value: 4 },
|
||||
{ label: '已发货', value: 5 },
|
||||
{ label: '已签收', value: 6 },
|
||||
{ label: '进行中', value: 7 },
|
||||
{ label: '暂不制药', value: 8 },
|
||||
{ label: '拒收', value: 9 },
|
||||
{ label: '退款', value: 10 },
|
||||
{ label: '保留药方', value: 11 },
|
||||
{ label: '制药缓发', value: 12 }
|
||||
]
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: myPatientOrderLists as any,
|
||||
params: formData,
|
||||
size: 15,
|
||||
firstLoading: true
|
||||
})
|
||||
|
||||
const summary = computed(() => ({
|
||||
orders: Number(pager.extend?.summary?.orders || 0),
|
||||
amount: Number(pager.extend?.summary?.amount || 0),
|
||||
pending: Number(pager.extend?.summary?.pending || 0),
|
||||
completed: Number(pager.extend?.summary?.completed || 0)
|
||||
}))
|
||||
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
|
||||
|
||||
function handleSearch() {
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function refreshPanel() {
|
||||
return getLists()
|
||||
}
|
||||
|
||||
function handleDateChange(value: string[] | null) {
|
||||
formData.start_date = value?.[0] || ''
|
||||
formData.end_date = value?.[1] || value?.[0] || ''
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
formData.keyword = ''
|
||||
formData.prescription_audit_status = ''
|
||||
formData.payment_slip_audit_status = ''
|
||||
formData.fulfillment_status = ''
|
||||
formData.start_date = ''
|
||||
formData.end_date = ''
|
||||
dateRange.value = []
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function money(value: number | string) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function auditTagType(status: number): 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (Number(status) === 1) return 'success'
|
||||
if (Number(status) === 2) return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function fulfillmentTagType(status: number): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||
const value = Number(status)
|
||||
if ([3, 6].includes(value)) return 'success'
|
||||
if ([4, 9, 10].includes(value)) return 'danger'
|
||||
if ([2, 5, 7].includes(value)) return 'primary'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function orderRowClassName({ row }: { row: any }) {
|
||||
if ([2].includes(Number(row.prescription_audit_status)) || [2].includes(Number(row.payment_slip_audit_status))) {
|
||||
return 'order-row-risk'
|
||||
}
|
||||
if ([3, 6].includes(Number(row.fulfillment_status))) return 'order-row-done'
|
||||
return ''
|
||||
}
|
||||
|
||||
function openOrderAction(row: Record<string, any>, action: MyPatientOrderAction) {
|
||||
orderActionHostRef.value?.openAction(row, action)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
|
||||
|
||||
onMounted(getLists)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.embedded-panel {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-actions,
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 10px;
|
||||
background: #fbfcfd;
|
||||
|
||||
:deep(.el-select) {
|
||||
width: 132px;
|
||||
}
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: min(340px, 100%);
|
||||
}
|
||||
|
||||
.date-range {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 82px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
span,
|
||||
small {
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin: 7px 0 3px;
|
||||
color: #172033;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-warning {
|
||||
border-color: #f3d8aa;
|
||||
background: #fffcf5;
|
||||
}
|
||||
|
||||
.metric-success {
|
||||
border-color: #b9e2dc;
|
||||
background: #f7fcfb;
|
||||
}
|
||||
|
||||
.embedded-table {
|
||||
width: 100%;
|
||||
border: 1px solid #e7ebf0;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(th.el-table__cell) {
|
||||
height: 44px;
|
||||
color: #5f6b7d;
|
||||
background: #f7f9fb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.order-row-risk > td.el-table__cell) {
|
||||
background: #fff8f7;
|
||||
}
|
||||
|
||||
:deep(.order-row-done > td.el-table__cell) {
|
||||
background: #f8fcfb;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-cell,
|
||||
.id-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
strong {
|
||||
color: #202939;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #8b96a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #d04f3f;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.danger-menu-item) {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.panel-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-panel > *,
|
||||
.filter-panel :deep(.el-select),
|
||||
.date-range {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,834 @@
|
||||
<template>
|
||||
<div class="progress-board">
|
||||
<section class="board-section overview-section">
|
||||
<div class="section-heading">
|
||||
<div class="heading-copy">
|
||||
<h2>今日面诊概览</h2>
|
||||
<span class="heading-badge">{{ isOwnershipMode ? '按本人归属' : '与排班合并' }}</span>
|
||||
</div>
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<article class="overview-card">
|
||||
<span>{{ isOwnershipMode ? '今日本人面诊' : '今日面诊总号源' }}</span>
|
||||
<strong>{{ todayOverview.totalVisits }}</strong>
|
||||
<small>{{ todayOverview.doctorCount }} 位{{ isOwnershipMode ? '接诊' : '排班' }}医生</small>
|
||||
</article>
|
||||
<article class="overview-card">
|
||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
|
||||
<strong>{{ todayOverview.booked }}</strong>
|
||||
<small>{{ isOwnershipMode ? '本人归属患者的有效挂号' : '按有效挂号占用号源' }}</small>
|
||||
</article>
|
||||
<article :class="['overview-card', isOwnershipMode ? 'overview-card-completed' : 'overview-card-empty']">
|
||||
<span>{{ isOwnershipMode ? '已完成' : '空号' }}</span>
|
||||
<strong>{{ isOwnershipMode ? todayOverview.completed : todayOverview.emptySlots }}</strong>
|
||||
<small>{{ isOwnershipMode ? `已过号 ${todayOverview.missed} 人` : '未被有效挂号占用' }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="board-section schedule-section">
|
||||
<div class="section-heading">
|
||||
<div class="heading-copy">
|
||||
<h2>{{ isOwnershipMode ? '近一周面诊安排' : '近一周排班' }}</h2>
|
||||
<span class="heading-badge">{{ isOwnershipMode ? '本人患者' : '今日起 7 天' }}</span>
|
||||
</div>
|
||||
<span class="schedule-range">{{ scheduleRange }}</span>
|
||||
</div>
|
||||
|
||||
<div class="schedule-grid">
|
||||
<button
|
||||
v-for="day in weekSchedule"
|
||||
:key="day.date"
|
||||
type="button"
|
||||
class="schedule-card"
|
||||
:class="{
|
||||
'is-today': day.date === today,
|
||||
'is-selected': day.date === selectedScheduleDate
|
||||
}"
|
||||
:aria-pressed="day.date === selectedScheduleDate"
|
||||
@click="selectScheduleDay(day.date)"
|
||||
>
|
||||
<span class="schedule-date">{{ day.date_text }} {{ day.weekday }}</span>
|
||||
<strong>{{ isOwnershipMode ? day.total_appointments : day.total_slots }}</strong>
|
||||
<span class="schedule-card-stats">
|
||||
<span>{{ isOwnershipMode ? '待诊' : '已约' }} {{ isOwnershipMode ? day.waiting_appointments : day.booked_slots }}</span>
|
||||
<i>/</i>
|
||||
<span :class="{ 'is-completed': isOwnershipMode }">{{ isOwnershipMode ? '完成' : '空' }} {{ isOwnershipMode ? day.completed_appointments : day.empty_slots }}</span>
|
||||
</span>
|
||||
<span class="schedule-card-action">
|
||||
<span>{{ day.doctor_count }} 位医生</span>
|
||||
<span>{{ day.date === selectedScheduleDate ? '正在查看' : '查看明细' }} ›</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="schedule-drilldown">
|
||||
<div class="drilldown-heading">
|
||||
<div>
|
||||
<h3>{{ selectedScheduleLabel }}{{ isOwnershipMode ? '面诊安排' : '排班明细' }}</h3>
|
||||
<p>共 {{ selectedScheduleDay.doctor_count }} 位{{ isOwnershipMode ? '接诊' : '排班' }}医生,点击上方日期可切换</p>
|
||||
</div>
|
||||
<div :class="['drilldown-summary', { 'is-ownership': isOwnershipMode }]">
|
||||
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.total_appointments : selectedScheduleDay.total_slots }}</strong></span>
|
||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.waiting_appointments : selectedScheduleDay.booked_slots }}</strong></span>
|
||||
<span>{{ isOwnershipMode ? '已完成' : '空号' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.completed_appointments : selectedScheduleDay.empty_slots }}</strong></span>
|
||||
<span v-if="isOwnershipMode">已过号 <strong>{{ selectedScheduleDay.missed_appointments }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedScheduleDoctors.length" class="doctor-schedule-list">
|
||||
<article
|
||||
v-for="(doctor, index) in selectedScheduleDoctors"
|
||||
:key="doctor.doctor_id"
|
||||
class="doctor-schedule-row"
|
||||
>
|
||||
<div class="doctor-identity">
|
||||
<span class="doctor-index">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<strong>{{ doctor.doctor_name }}</strong>
|
||||
<small>{{ isOwnershipMode ? '本人患者接诊医生' : '医生排班' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doctor-windows">
|
||||
<span>{{ isOwnershipMode ? '预约时刻' : '排班时段' }}</span>
|
||||
<strong>{{ doctorWindows(doctor) }}</strong>
|
||||
</div>
|
||||
<div class="doctor-metric">
|
||||
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }}</span>
|
||||
<strong>{{ isOwnershipMode ? doctor.total_appointments : doctor.total_slots }}</strong>
|
||||
</div>
|
||||
<div class="doctor-metric doctor-metric-booked">
|
||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
|
||||
<strong>{{ isOwnershipMode ? doctor.waiting_appointments : doctor.booked_slots }}</strong>
|
||||
</div>
|
||||
<div :class="['doctor-metric', isOwnershipMode ? 'doctor-metric-completed' : 'doctor-metric-empty']">
|
||||
<span>{{ isOwnershipMode ? '完成 / 过号' : '空号' }}</span>
|
||||
<strong>{{ isOwnershipMode ? `${doctor.completed_appointments} / ${doctor.missed_appointments}` : doctor.empty_slots }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty
|
||||
v-else
|
||||
:image-size="56"
|
||||
:description="isOwnershipMode ? '该日期暂无本人归属患者的有效挂号' : '该日期暂无当前权限范围内的医生排班'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="board-section queue-section">
|
||||
<div class="section-heading queue-heading">
|
||||
<div class="heading-copy">
|
||||
<h2>候诊列表</h2>
|
||||
<span class="heading-badge">按医生排队</span>
|
||||
<span class="queue-count">共 {{ pager.count }} 人</span>
|
||||
</div>
|
||||
<span class="refresh-time">每 15 秒自动刷新</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
class="queue-table"
|
||||
:row-class-name="tableRowClassName"
|
||||
@row-dblclick="openDiagnosis"
|
||||
>
|
||||
<el-table-column label="排队" width="92">
|
||||
<template #default="{ row }">
|
||||
<span class="queue-number">{{ row.queue_no || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="患者" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="patient-cell">
|
||||
<strong>{{ row.patient_name || '未命名患者' }}</strong>
|
||||
<span>{{ row.phone_masked || '无手机号' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="doctor_name" label="医生" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="预约时间" min-width="135">
|
||||
<template #default="{ row }">
|
||||
<span class="appointment-clock">{{ appointmentClock(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="前方等待" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span :class="['waiting-text', `is-${row.queue_status || 'waiting'}`]">
|
||||
{{ waitingText(row) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" round effect="light" :type="queueTagType(row.queue_status)">
|
||||
{{ row.queue_status_text || '等待中' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="当前权限范围内今日暂无候诊患者" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="pager.count > pager.size" class="pagination-wrap">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { myPatientProgressLists } from '@/api/first_visit'
|
||||
|
||||
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
const selectedScheduleDate = ref(today)
|
||||
const formData = reactive({
|
||||
keyword: '',
|
||||
status: 1 as const,
|
||||
start_date: today,
|
||||
end_date: today
|
||||
})
|
||||
|
||||
const { pager, getLists } = usePaging({
|
||||
fetchFun: myPatientProgressLists as any,
|
||||
params: formData,
|
||||
size: 15,
|
||||
firstLoading: true
|
||||
})
|
||||
|
||||
const todayOverview = computed(() => ({
|
||||
totalVisits: Number(pager.extend?.today_overview?.total_visits || 0),
|
||||
booked: Number(pager.extend?.today_overview?.booked || 0),
|
||||
completed: Number(pager.extend?.today_overview?.completed || 0),
|
||||
missed: Number(pager.extend?.today_overview?.missed || 0),
|
||||
emptySlots: Number(pager.extend?.today_overview?.empty_slots || 0),
|
||||
doctorCount: Number(pager.extend?.today_overview?.doctor_count || 0)
|
||||
}))
|
||||
const isOwnershipMode = computed(() => pager.extend?.schedule_mode === 'ownership')
|
||||
|
||||
const weekSchedule = computed(() => {
|
||||
const rows = Array.isArray(pager.extend?.week_schedule) ? pager.extend.week_schedule : []
|
||||
if (rows.length) return rows
|
||||
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = dayjs().add(index, 'day')
|
||||
return {
|
||||
date: date.format('YYYY-MM-DD'),
|
||||
date_text: date.format('MM-DD'),
|
||||
weekday: `周${'日一二三四五六'[date.day()]}`,
|
||||
total_slots: 0,
|
||||
booked_slots: 0,
|
||||
empty_slots: 0,
|
||||
doctor_count: 0,
|
||||
doctors: [],
|
||||
total_appointments: 0,
|
||||
waiting_appointments: 0,
|
||||
completed_appointments: 0,
|
||||
missed_appointments: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const selectedScheduleDay = computed(() => {
|
||||
return weekSchedule.value.find((day: any) => day.date === selectedScheduleDate.value) || weekSchedule.value[0] || {
|
||||
date: today,
|
||||
date_text: dayjs().format('MM-DD'),
|
||||
weekday: `周${'日一二三四五六'[dayjs().day()]}`,
|
||||
total_slots: 0,
|
||||
booked_slots: 0,
|
||||
empty_slots: 0,
|
||||
doctor_count: 0,
|
||||
doctors: [],
|
||||
total_appointments: 0,
|
||||
waiting_appointments: 0,
|
||||
completed_appointments: 0,
|
||||
missed_appointments: 0
|
||||
}
|
||||
})
|
||||
const selectedScheduleDoctors = computed(() => {
|
||||
return Array.isArray(selectedScheduleDay.value?.doctors) ? selectedScheduleDay.value.doctors : []
|
||||
})
|
||||
const selectedScheduleLabel = computed(() => {
|
||||
const day = selectedScheduleDay.value
|
||||
return `${day.date_text || ''} ${day.weekday || ''} `
|
||||
})
|
||||
|
||||
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
|
||||
const scheduleRange = computed(() => {
|
||||
const rows = weekSchedule.value
|
||||
if (!rows.length) return ''
|
||||
return `${rows[0].date_text} 至 ${rows[rows.length - 1].date_text}`
|
||||
})
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function refreshPanel(options?: { silent?: boolean }) {
|
||||
return getLists(options)
|
||||
}
|
||||
|
||||
function selectScheduleDay(date: string) {
|
||||
selectedScheduleDate.value = date
|
||||
}
|
||||
|
||||
function doctorWindows(doctor: Record<string, any>) {
|
||||
const source = isOwnershipMode.value ? doctor.appointment_times : doctor.schedule_windows
|
||||
const windows = Array.isArray(source) ? source.filter(Boolean) : []
|
||||
if (windows.length) return windows.join('、')
|
||||
return isOwnershipMode.value ? '暂无预约时刻' : '未设置具体时段'
|
||||
}
|
||||
|
||||
function appointmentClock(row: Record<string, any>) {
|
||||
const time = String(row.appointment_time || '').slice(0, 5)
|
||||
return time || '—'
|
||||
}
|
||||
|
||||
function waitingText(row: Record<string, any>) {
|
||||
if (row.queue_status === 'consulting') return '0(进行中)'
|
||||
if (row.queue_status === 'next') return '0(待接诊)'
|
||||
const ahead = Math.max(0, Number(row.ahead_count || 0))
|
||||
if (ahead === 0) return '0 位'
|
||||
return `${ahead} 位 · 约 ${Number(row.estimated_wait_minutes || ahead * 15)} 分钟`
|
||||
}
|
||||
|
||||
function queueTagType(status: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (status === 'consulting') return 'success'
|
||||
if (status === 'next') return 'warning'
|
||||
if (status === 'completed') return 'success'
|
||||
if (status === 'missed') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function tableRowClassName({ row }: { row: Record<string, any> }) {
|
||||
return Number(row.is_self_patient) === 1 ? 'queue-row-self' : ''
|
||||
}
|
||||
|
||||
function openDiagnosis(row: Record<string, any>) {
|
||||
emit('openDiagnosis', row)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
|
||||
|
||||
onMounted(() => {
|
||||
getLists()
|
||||
refreshTimer = setInterval(() => getLists({ silent: true }), 15_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.progress-board {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.board-section {
|
||||
padding: 16px;
|
||||
border: 1px solid #e1e7ee;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.heading-copy,
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heading-copy {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.heading-badge {
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
color: #758195;
|
||||
background: #eef2f6;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
gap: 5px;
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
min-height: 78px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #dfe5ed;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
> span,
|
||||
> small {
|
||||
display: block;
|
||||
color: #778398;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
> strong {
|
||||
display: block;
|
||||
margin: 7px 0 5px;
|
||||
color: #111a2c;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
> small {
|
||||
color: #a0a9b7;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.overview-card-empty > strong {
|
||||
color: #ee4d55;
|
||||
}
|
||||
|
||||
.overview-card-completed > strong {
|
||||
color: #07886d;
|
||||
}
|
||||
|
||||
.schedule-range,
|
||||
.refresh-time,
|
||||
.queue-count {
|
||||
color: #929dac;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.schedule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
min-width: 0;
|
||||
padding: 11px 8px;
|
||||
text-align: center;
|
||||
border: 1px solid #dfe5ed;
|
||||
border-radius: 9px;
|
||||
background: #fbfcfe;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #7ac9c2;
|
||||
background: #f7fcfb;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid rgba(15, 145, 133, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.is-today {
|
||||
border-color: #8fd3cd;
|
||||
background: #f4fbfa;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: #0f9185;
|
||||
background: #effaf8;
|
||||
box-shadow: 0 8px 20px rgba(15, 118, 110, 0.1), inset 0 -3px 0 #0f9185;
|
||||
}
|
||||
|
||||
> strong {
|
||||
display: block;
|
||||
margin: 7px 0 5px;
|
||||
color: #131c2d;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-date {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #70809a;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-card-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: #0c9967;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
color: #a5afbd;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
color: #d8862b;
|
||||
}
|
||||
|
||||
span.is-completed {
|
||||
color: #07886d;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-card-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin: 10px 2px 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e7ecef;
|
||||
color: #7b8799;
|
||||
font-size: 10px;
|
||||
|
||||
span:last-child {
|
||||
color: #0f8077;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-drilldown {
|
||||
margin-top: 14px;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
background: #f7f9fb;
|
||||
}
|
||||
|
||||
.drilldown-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 11px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #8a95a6;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.drilldown-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
color: #7a8698;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
strong {
|
||||
margin-left: 3px;
|
||||
color: #243044;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
span:last-child strong {
|
||||
color: #d17820;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-schedule-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.doctor-schedule-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.8fr) repeat(3, minmax(64px, 0.55fr));
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 58px;
|
||||
padding: 8px 13px;
|
||||
border: 1px solid #e4e9ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.doctor-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
> div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
strong,
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
overflow: hidden;
|
||||
color: #202b3d;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-top: 2px;
|
||||
color: #9aa4b2;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-index {
|
||||
display: grid;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border-radius: 7px;
|
||||
color: #0f766e;
|
||||
background: #e7f6f4;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doctor-windows,
|
||||
.doctor-metric {
|
||||
min-width: 0;
|
||||
|
||||
span,
|
||||
strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #98a2b3;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-top: 3px;
|
||||
color: #344054;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-windows strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doctor-metric {
|
||||
text-align: right;
|
||||
|
||||
strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-metric-booked strong {
|
||||
color: #07886d;
|
||||
}
|
||||
|
||||
.doctor-metric-empty strong {
|
||||
color: #d17820;
|
||||
}
|
||||
|
||||
.doctor-metric-completed strong {
|
||||
color: #07886d;
|
||||
}
|
||||
|
||||
.queue-heading {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.queue-table {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(th.el-table__cell) {
|
||||
height: 40px;
|
||||
color: #667085;
|
||||
background: #f7f9fb;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(td.el-table__cell) {
|
||||
height: 46px;
|
||||
padding: 6px 0;
|
||||
color: #253044;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.queue-row-self > td.el-table__cell) {
|
||||
background: #fff8e8 !important;
|
||||
}
|
||||
|
||||
:deep(.queue-row-self > td.el-table__cell:first-child) {
|
||||
border-left: 3px solid #f08332;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-number {
|
||||
display: inline-grid;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 7px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
color: #0f766e;
|
||||
background: #e7f6f4;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.patient-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
strong {
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #98a2b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.appointment-clock {
|
||||
color: #263246;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.waiting-text {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
|
||||
&.is-consulting {
|
||||
color: #07886d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.is-next {
|
||||
color: #d17820;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.schedule-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.doctor-schedule-row {
|
||||
grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.5fr) repeat(3, minmax(56px, 0.5fr));
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.board-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.overview-grid,
|
||||
.schedule-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.heading-copy {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.drilldown-heading,
|
||||
.drilldown-summary {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drilldown-summary {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.doctor-schedule-row {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.doctor-identity,
|
||||
.doctor-windows {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.doctor-metric {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import { isRemoteSnapshotLocked } from '@/views/consumer/prescription/components/prescription-order-utils'
|
||||
|
||||
export type MyPatientOrderAction =
|
||||
| 'edit'
|
||||
| 'audit_prescription'
|
||||
| 'revoke_rx_audit'
|
||||
| 'audit_payment'
|
||||
| 'revoke_pay_audit'
|
||||
| 'ddcode'
|
||||
| 'ship'
|
||||
| 'add_pay_order'
|
||||
| 'complete'
|
||||
| 'refund'
|
||||
| 'withdraw'
|
||||
| 'upload_pharmacy'
|
||||
|
||||
export interface MyPatientOrderActionItem {
|
||||
key: MyPatientOrderAction
|
||||
label: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
function permitted(permission: string) {
|
||||
return hasPermission([permission])
|
||||
}
|
||||
|
||||
function remoteLocked(row: Record<string, any>) {
|
||||
return isRemoteSnapshotLocked(row as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function canViewMyPatientOrderDetail() {
|
||||
return permitted('tcm.prescriptionOrder/detail')
|
||||
}
|
||||
|
||||
export function myPatientOrderActions(row: Record<string, any>): MyPatientOrderActionItem[] {
|
||||
const actions: MyPatientOrderActionItem[] = []
|
||||
const fulfillment = Number(row.fulfillment_status)
|
||||
const prescriptionAudit = Number(row.prescription_audit_status)
|
||||
const paymentAudit = Number(row.payment_slip_audit_status)
|
||||
const locked = remoteLocked(row)
|
||||
|
||||
if (
|
||||
canViewMyPatientOrderDetail()
|
||||
&& permitted('tcm.prescriptionOrder/edit')
|
||||
&& fulfillment === 1
|
||||
&& !locked
|
||||
) {
|
||||
actions.push({ key: 'edit', label: '编辑订单' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPrescription')
|
||||
&& prescriptionAudit === 0
|
||||
&& ![3, 4, 6].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'audit_prescription', label: '处方审核' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPrescription')
|
||||
&& [1, 2].includes(prescriptionAudit)
|
||||
&& paymentAudit === 0
|
||||
&& ![3, 4, 6].includes(fulfillment)
|
||||
&& !locked
|
||||
) {
|
||||
actions.push({ key: 'revoke_rx_audit', label: '撤回处方审核' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPayment')
|
||||
&& prescriptionAudit === 1
|
||||
&& paymentAudit === 0
|
||||
&& ![3, 4].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'audit_payment', label: '支付单审核' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPayment')
|
||||
&& prescriptionAudit === 1
|
||||
&& [1, 2].includes(paymentAudit)
|
||||
&& ![3, 4, 6].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'revoke_pay_audit', label: '撤回支付审核' })
|
||||
}
|
||||
if (permitted('tcm.prescriptionOrder/ddcode')) {
|
||||
actions.push({ key: 'ddcode', label: '修改快递单号' })
|
||||
}
|
||||
if (permitted('tcm.prescriptionOrder/ship') && fulfillment === 2) {
|
||||
actions.push({ key: 'ship', label: '确认发货' })
|
||||
}
|
||||
const amount = Math.round((Number(row.amount) || 0) * 100) / 100
|
||||
const paidTotal = Math.round((Number(row.linked_pay_paid_total) || 0) * 100) / 100
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/addPayOrder')
|
||||
&& [5, 6].includes(fulfillment)
|
||||
&& paidTotal < amount
|
||||
) {
|
||||
actions.push({ key: 'add_pay_order', label: '补齐支付单' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/complete')
|
||||
&& [5, 6].includes(fulfillment)
|
||||
&& paymentAudit === 1
|
||||
) {
|
||||
actions.push({ key: 'complete', label: '完成订单' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/refund')
|
||||
&& [3, 5, 6, 9].includes(fulfillment)
|
||||
&& paymentAudit === 1
|
||||
) {
|
||||
actions.push({ key: 'refund', label: '退款', danger: true })
|
||||
}
|
||||
if (permitted('tcm.prescriptionOrder/withdraw') && fulfillment === 1 && !locked) {
|
||||
actions.push({ key: 'withdraw', label: '撤回订单', danger: true })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/uploadToPharmacy')
|
||||
&& row.can_upload_pharmacy !== false
|
||||
&& prescriptionAudit === 1
|
||||
&& ![3, 4, 8, 10, 11, 12].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'upload_pharmacy', label: '上传药房' })
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
<template>
|
||||
<div class="my-patients-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<div class="eyebrow">一诊</div>
|
||||
<h1>我的患者</h1>
|
||||
<p>患者、挂号与诊单信息按当前角色和部门数据范围展示</p>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<el-tag effect="plain" round>{{ scopeLabel }}</el-tag>
|
||||
<el-button :icon="Refresh" :loading="workspaceLoading" @click="refreshPage">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="workspace-card" shadow="never">
|
||||
<el-tabs v-model="activeWorkspace" class="workspace-tabs">
|
||||
<el-tab-pane label="患者列表" name="patients" />
|
||||
<el-tab-pane label="订单管理" name="orders" />
|
||||
<el-tab-pane label="面诊进度" name="progress" />
|
||||
</el-tabs>
|
||||
|
||||
<div v-show="activeWorkspace === 'patients'">
|
||||
<div class="filter-panel">
|
||||
<div class="filter-row filter-row-main">
|
||||
<el-input
|
||||
v-model="formData.keyword"
|
||||
class="keyword-input"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
placeholder="患者姓名 / 手机号 / 助理 / 医生"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<div class="filter-group status-group">
|
||||
<span class="filter-label">状态</span>
|
||||
<el-radio-group v-model="formData.status_filter" @change="handleFilterChange">
|
||||
<el-radio-button v-for="item in statusOptions" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row date-filter-row">
|
||||
<span class="filter-label">挂号时间</span>
|
||||
<div class="quick-dates">
|
||||
<el-button
|
||||
v-for="item in dateOptions"
|
||||
:key="item.value"
|
||||
:type="activeDateType === item.value ? 'primary' : 'default'"
|
||||
@click="selectDateType(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-date-picker
|
||||
v-model="customDateRange"
|
||||
class="date-range"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:clearable="true"
|
||||
@change="handleCustomDateChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<button class="summary-card summary-today" type="button" @click="selectDateType('today')">
|
||||
<span class="summary-icon"><el-icon><Calendar /></el-icon></span>
|
||||
<span class="summary-copy"><small>今日挂号</small><strong>{{ summary.today }}</strong><em>人</em></span>
|
||||
<span class="summary-date">{{ summaryDates.today || '—' }}</span>
|
||||
</button>
|
||||
<button class="summary-card summary-tomorrow" type="button" @click="selectDateType('tomorrow')">
|
||||
<span class="summary-icon"><el-icon><Clock /></el-icon></span>
|
||||
<span class="summary-copy"><small>明日预约</small><strong>{{ summary.tomorrow }}</strong><em>人</em></span>
|
||||
<span class="summary-date">{{ summaryDates.tomorrow || '—' }}</span>
|
||||
</button>
|
||||
<button class="summary-card summary-after" type="button" @click="selectDateType('day_after')">
|
||||
<span class="summary-icon"><el-icon><Calendar /></el-icon></span>
|
||||
<span class="summary-copy"><small>后天预约</small><strong>{{ summary.day_after }}</strong><em>人</em></span>
|
||||
<span class="summary-date">{{ summaryDates.day_after || '—' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-heading">
|
||||
<div>
|
||||
<h2>患者列表</h2>
|
||||
<span>共 {{ pager.count }} 位患者</span>
|
||||
</div>
|
||||
<span class="table-scope"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
class="patient-table"
|
||||
:row-class-name="tableRowClassName"
|
||||
@row-dblclick="openDiagnosis"
|
||||
>
|
||||
<el-table-column label="患者" min-width="190" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="patient-cell">
|
||||
<span class="patient-avatar">{{ patientInitial(row.patient_name) }}</span>
|
||||
<div>
|
||||
<strong>{{ row.patient_name || '未命名患者' }}</strong>
|
||||
<p>{{ row.gender_desc }} · {{ row.age || '—' }}岁 · {{ row.phone_masked || '无手机号' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assistant_name" label="归属助理" min-width="120" />
|
||||
<el-table-column label="预约医生" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="doctor-cell">
|
||||
<span>{{ row.appointment_doctor_name || '未预约' }}</span>
|
||||
<el-tag v-if="row.appointment_status" size="small" :type="appointmentTagType(row.appointment_status)" effect="light">
|
||||
{{ row.appointment_status_text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约时间" min-width="165">
|
||||
<template #default="{ row }">
|
||||
<span :class="['appointment-time', { empty: !row.appointment_time_text }]">
|
||||
{{ row.appointment_time_text || '暂无预约' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="复诊次数" width="105" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="revisit-count">{{ row.revisit_count || 0 }} 次</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="确认信息" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.confirmed ? 'success' : 'warning'" effect="light">
|
||||
{{ row.confirmation_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="诊单日期" width="118">
|
||||
<template #default="{ row }">{{ row.diagnosis_date_text || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="205" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button v-if="canEditDiagnosis" type="primary" link @click="openDiagnosis(row)">诊单</el-button>
|
||||
<el-button v-else-if="canReadDiagnosis" type="primary" link @click="openReadonlyDiagnosis(row)">查看</el-button>
|
||||
<el-button v-if="canBookAppointment" type="primary" link @click="openAppointment(row)">预约</el-button>
|
||||
<el-button
|
||||
v-if="canBookAppointment && canCancelAppointment(row)"
|
||||
type="danger"
|
||||
link
|
||||
@click="cancelAppointmentForRow(row)"
|
||||
>
|
||||
取消挂号
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="当前范围内暂无患者" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<order-panel
|
||||
v-if="activeWorkspace === 'orders'"
|
||||
ref="orderPanelRef"
|
||||
@open-diagnosis="openDiagnosis"
|
||||
/>
|
||||
<progress-panel
|
||||
v-if="activeWorkspace === 'progress'"
|
||||
ref="progressPanelRef"
|
||||
@open-diagnosis="openDiagnosis"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<edit-popup ref="editRef" @success="refreshPage" />
|
||||
<appointment-popup ref="appointmentRef" api-scene="my_patient" @success="refreshPage" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { Calendar, Clock, Lock, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { myPatientCancelAppointment, myPatientLists } from '@/api/first_visit'
|
||||
|
||||
const EditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
const AppointmentPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/appointment.vue'))
|
||||
const OrderPanel = defineAsyncComponent(() => import('./components/OrderPanel.vue'))
|
||||
const ProgressPanel = defineAsyncComponent(() => import('./components/ProgressPanel.vue'))
|
||||
|
||||
type StatusFilter = '' | 'unconfirmed' | 'booked' | 'completed' | 'missed'
|
||||
type DateType = 'all' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom'
|
||||
|
||||
const router = useRouter()
|
||||
const activeWorkspace = ref('patients')
|
||||
const activeDateType = ref<DateType>('all')
|
||||
const customDateRange = ref<string[]>([])
|
||||
const editRef = ref<any>()
|
||||
const appointmentRef = ref<any>()
|
||||
const orderPanelRef = ref<any>()
|
||||
const progressPanelRef = ref<any>()
|
||||
|
||||
const formData = reactive({
|
||||
keyword: '',
|
||||
status_filter: '' as StatusFilter,
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const statusOptions: Array<{ label: string; value: StatusFilter }> = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '未确认', value: 'unconfirmed' },
|
||||
{ label: '已挂号', value: 'booked' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '已过号', value: 'missed' }
|
||||
]
|
||||
|
||||
const dateOptions: Array<{ label: string; value: DateType }> = [
|
||||
{ label: '全部时间', value: 'all' },
|
||||
{ label: '今日挂号', value: 'today' },
|
||||
{ label: '明日', value: 'tomorrow' },
|
||||
{ label: '后天', value: 'day_after' },
|
||||
{ label: '近7天', value: 'last7' },
|
||||
{ label: '近30天', value: 'last30' }
|
||||
]
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: myPatientLists as any,
|
||||
params: formData,
|
||||
size: 15,
|
||||
firstLoading: true
|
||||
})
|
||||
|
||||
const summary = computed(() => ({
|
||||
today: Number(pager.extend?.summary?.today || 0),
|
||||
tomorrow: Number(pager.extend?.summary?.tomorrow || 0),
|
||||
day_after: Number(pager.extend?.summary?.day_after || 0)
|
||||
}))
|
||||
const summaryDates = computed(() => pager.extend?.dates || {})
|
||||
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
|
||||
const canEditDiagnosis = computed(() => hasPermission(['tcm.diagnosis/edit']))
|
||||
const canReadDiagnosis = computed(() => hasPermission(['tcm.diagnosis/readonlyDetail']))
|
||||
const canBookAppointment = computed(() => hasPermission(['tcm.diagnosis/guahao']))
|
||||
const workspaceLoading = computed(() => {
|
||||
if (activeWorkspace.value === 'orders') return Boolean(orderPanelRef.value?.loading)
|
||||
if (activeWorkspace.value === 'progress') return Boolean(progressPanelRef.value?.loading)
|
||||
return pager.loading
|
||||
})
|
||||
|
||||
function handleSearch() {
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function handleFilterChange() {
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function refreshPage() {
|
||||
if (activeWorkspace.value === 'orders') return orderPanelRef.value?.refresh?.()
|
||||
if (activeWorkspace.value === 'progress') return progressPanelRef.value?.refresh?.()
|
||||
return getLists()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
formData.keyword = ''
|
||||
formData.status_filter = ''
|
||||
formData.start_date = ''
|
||||
formData.end_date = ''
|
||||
activeDateType.value = 'all'
|
||||
customDateRange.value = []
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function selectDateType(type: DateType) {
|
||||
activeDateType.value = type
|
||||
customDateRange.value = []
|
||||
const today = dayjs()
|
||||
if (type === 'all') {
|
||||
formData.start_date = ''
|
||||
formData.end_date = ''
|
||||
} else if (type === 'today') {
|
||||
formData.start_date = today.format('YYYY-MM-DD')
|
||||
formData.end_date = formData.start_date
|
||||
} else if (type === 'tomorrow') {
|
||||
formData.start_date = today.add(1, 'day').format('YYYY-MM-DD')
|
||||
formData.end_date = formData.start_date
|
||||
} else if (type === 'day_after') {
|
||||
formData.start_date = today.add(2, 'day').format('YYYY-MM-DD')
|
||||
formData.end_date = formData.start_date
|
||||
} else if (type === 'last7') {
|
||||
formData.start_date = today.subtract(6, 'day').format('YYYY-MM-DD')
|
||||
formData.end_date = today.format('YYYY-MM-DD')
|
||||
} else if (type === 'last30') {
|
||||
formData.start_date = today.subtract(29, 'day').format('YYYY-MM-DD')
|
||||
formData.end_date = today.format('YYYY-MM-DD')
|
||||
}
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function handleCustomDateChange(value: string[] | null) {
|
||||
if (!value?.length) {
|
||||
activeDateType.value = 'all'
|
||||
formData.start_date = ''
|
||||
formData.end_date = ''
|
||||
} else {
|
||||
activeDateType.value = 'custom'
|
||||
formData.start_date = value[0] || ''
|
||||
formData.end_date = value[1] || value[0] || ''
|
||||
}
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function openDiagnosis(row: any) {
|
||||
if (!canEditDiagnosis.value) {
|
||||
openReadonlyDiagnosis(row)
|
||||
return
|
||||
}
|
||||
editRef.value?.open?.('edit', Number(row.diagnosis_id || row.id))
|
||||
}
|
||||
|
||||
function openReadonlyDiagnosis(row: any) {
|
||||
if (!canReadDiagnosis.value) {
|
||||
feedback.msgWarning('当前角色没有诊单查看权限')
|
||||
return
|
||||
}
|
||||
const route = router.getRoutes().find((item) => item.meta?.perms === 'tcm.diagnosis/readonlyDetail')
|
||||
if (route?.path) {
|
||||
router.push({ path: route.path, query: { id: String(row.diagnosis_id || row.id) } })
|
||||
} else {
|
||||
router.push({ path: '/tcm/diagnosis-readonly', query: { id: String(row.diagnosis_id || row.id) } })
|
||||
}
|
||||
}
|
||||
|
||||
function openAppointment(row: any) {
|
||||
appointmentRef.value?.open?.({
|
||||
...row,
|
||||
id: Number(row.diagnosis_id || row.id),
|
||||
patient_id: Number(row.diagnosis_id || row.id)
|
||||
})
|
||||
}
|
||||
|
||||
function canCancelAppointment(row: any) {
|
||||
return Number(row.appointment_id) > 0 && [1, 4].includes(Number(row.appointment_status))
|
||||
}
|
||||
|
||||
async function cancelAppointmentForRow(row: any) {
|
||||
if (!canCancelAppointment(row)) return
|
||||
try {
|
||||
await feedback.confirm(`确定取消“${row.patient_name || '该患者'}”的挂号吗?`)
|
||||
await myPatientCancelAppointment({ id: Number(row.appointment_id) })
|
||||
feedback.msgSuccess('取消挂号成功')
|
||||
await refreshPage()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
feedback.msgError(error?.msg || '取消挂号失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function patientInitial(name: string) {
|
||||
const text = String(name || '患').trim()
|
||||
return text.slice(0, 1)
|
||||
}
|
||||
|
||||
function appointmentTagType(status: number): 'primary' | 'success' | 'warning' | 'info' {
|
||||
return ({ 1: 'primary', 3: 'success', 4: 'warning' } as const)[Number(status) as 1 | 3 | 4] || 'info'
|
||||
}
|
||||
|
||||
function tableRowClassName({ row }: { row: any }) {
|
||||
if (!row.confirmed) return 'patient-row-unconfirmed'
|
||||
if (Number(row.appointment_status) === 4) return 'patient-row-missed'
|
||||
return ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getLists()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-patients-page {
|
||||
min-height: calc(100vh - 90px);
|
||||
padding: 18px;
|
||||
background: #f5f7fa;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 3px;
|
||||
color: #0f9185;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0 0;
|
||||
color: #8490a3;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.heading-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
:deep(.el-tag) {
|
||||
color: #0f766e;
|
||||
border-color: #a7ded8;
|
||||
background: #effbf9;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 12px;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0 -16px 16px;
|
||||
padding: 0 16px;
|
||||
background: #fbfcfd;
|
||||
border-bottom: 1px solid #e9edf2;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
height: 50px;
|
||||
padding: 0 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__active-bar) {
|
||||
height: 3px;
|
||||
background: #0f9185;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
padding: 14px;
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 10px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-row-main {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: min(390px, 100%);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
flex: 0 0 auto;
|
||||
color: #5d687a;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date-filter-row {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e8edf2;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-dates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.date-range {
|
||||
width: 280px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 96px;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
border: 1px solid #dfe6ee;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #82cfc7;
|
||||
box-shadow: 0 8px 20px rgba(28, 74, 70, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-icon {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-right: 13px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: #0f9185;
|
||||
background: #e8f7f5;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.summary-copy {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
|
||||
small {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
color: #718096;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-top: 20px;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
em {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-date {
|
||||
margin-left: auto;
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-tomorrow .summary-icon {
|
||||
color: #3978d6;
|
||||
background: #edf4ff;
|
||||
}
|
||||
|
||||
.summary-after .summary-icon {
|
||||
color: #c27728;
|
||||
background: #fff4e8;
|
||||
}
|
||||
|
||||
.table-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 4px 0 10px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-scope {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.patient-table {
|
||||
width: 100%;
|
||||
border: 1px solid #e7ebf0;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(th.el-table__cell) {
|
||||
height: 44px;
|
||||
color: #5f6b7d;
|
||||
background: #f7f9fb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(td.el-table__cell) {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
:deep(.patient-row-unconfirmed > td.el-table__cell) {
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
:deep(.patient-row-missed > td.el-table__cell) {
|
||||
background: #fffdf8;
|
||||
}
|
||||
}
|
||||
|
||||
.patient-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
color: #202939;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #8b96a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.patient-avatar {
|
||||
display: grid;
|
||||
flex: 0 0 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: #0f766e;
|
||||
background: #e5f5f2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doctor-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.appointment-time {
|
||||
color: #344054;
|
||||
|
||||
&.empty {
|
||||
color: #98a2b3;
|
||||
}
|
||||
}
|
||||
|
||||
.revisit-count {
|
||||
color: #475467;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.date-range {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.my-patients-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-row,
|
||||
.filter-group {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-group :deep(.el-radio-group) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,602 @@
|
||||
<template>
|
||||
<div
|
||||
class="registration-stats"
|
||||
v-loading="loading"
|
||||
element-loading-text="正在汇总权限范围内的挂号数据"
|
||||
>
|
||||
<header class="page-heading">
|
||||
<div class="heading-copy">
|
||||
<span class="heading-mark"><el-icon><Histogram /></el-icon></span>
|
||||
<div>
|
||||
<h1>挂号统计</h1>
|
||||
<p>挂号、诊单与目标数据按当前角色和部门权限实时汇总</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
|
||||
<span v-if="dashboard.meta.generated_at" class="update-time">更新于 {{ dashboard.meta.generated_at }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip">
|
||||
<div class="filter-item">
|
||||
<span>部门</span>
|
||||
<el-tree-select
|
||||
v-model="query.dept_id"
|
||||
:data="dashboard.filters.departments"
|
||||
:props="deptTreeProps"
|
||||
node-key="id"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="全部可见部门"
|
||||
class="dept-select"
|
||||
@change="handleDepartmentChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item filter-item--time">
|
||||
<span>时间</span>
|
||||
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span>员工</span>
|
||||
<el-select
|
||||
v-model="query.assistant_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部可见员工"
|
||||
class="employee-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in dashboard.filters.assistants"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="Number(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-summary">
|
||||
<strong>{{ dashboard.meta.start_date || '—' }}</strong>
|
||||
<span>至 {{ dashboard.meta.end_date || '—' }} · {{ dashboard.meta.member_count || 0 }} 位员工</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="挂号统计核心指标">
|
||||
<article class="metric-card metric-card--teal">
|
||||
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总挂号</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.appointment_count) }}</strong>
|
||||
<small :class="compareClass(dashboard.summary.appointment_compare_rate)">
|
||||
{{ compareText(dashboard.summary.appointment_compare_rate) }}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--blue">
|
||||
<div class="metric-icon"><el-icon><DocumentChecked /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总诊单</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.order_count) }}</strong>
|
||||
<small>排除取消、拒收和退款订单</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--amber">
|
||||
<div class="metric-icon"><el-icon><Wallet /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总业绩</span>
|
||||
<strong>{{ formatMoney(dashboard.summary.order_amount) }}</strong>
|
||||
<small>按业务订单创建人归属</small>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel employee-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>本组员工挂号统计</h2>
|
||||
<p>部门汇总可展开查看员工;明日、后日预约始终使用对应自然日</p>
|
||||
</div>
|
||||
<span class="panel-badge">{{ dashboard.meta.time_label || '当前范围' }}</span>
|
||||
</div>
|
||||
<el-table
|
||||
:data="dashboard.employee_rows"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
class="stats-table"
|
||||
>
|
||||
<el-table-column prop="name" label="部门 / 员工" min-width="250" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="name-cell" :class="`is-${row.row_type}`">
|
||||
<span v-if="row.row_type === 'department'" class="dept-dot" />
|
||||
<el-avatar v-else :size="26">{{ avatarText(row.name) }}</el-avatar>
|
||||
<strong>{{ row.name }}</strong>
|
||||
<em v-if="row.row_type === 'department'">{{ row.member_count }} 人</em>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
|
||||
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
|
||||
<el-table-column prop="day_after_count" label="后日预约" min-width="105" align="right" sortable />
|
||||
<el-table-column prop="order_count" label="诊单" min-width="86" align="right" sortable />
|
||||
<el-table-column label="业绩" min-width="128" align="right" sortable :sort-method="sortAmount">
|
||||
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="较上期" min-width="105" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
|
||||
{{ compactCompare(row.appointment_compare_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="92" align="center">
|
||||
<template #default><span class="status-pill"><i />正常</span></template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限与筛选范围内暂无医助数据" /></template>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel target-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>一诊诊金目标追踪</h2>
|
||||
<p>{{ dashboard.target.year }} 年 · {{ dashboard.target.scope_note }}</p>
|
||||
</div>
|
||||
<span class="panel-badge">{{ dashboard.target.department_count || 0 }} 个目标部门</span>
|
||||
</div>
|
||||
<div class="target-layout">
|
||||
<div class="target-summary">
|
||||
<div class="target-numbers">
|
||||
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
|
||||
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
|
||||
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="progressValue(dashboard.target.completion_rate)"
|
||||
:show-text="false"
|
||||
:stroke-width="13"
|
||||
color="#139a8c"
|
||||
/>
|
||||
<p v-if="Number(dashboard.target.target_amount) > 0">
|
||||
尚差 {{ formatMoney(Math.max(0, Number(dashboard.target.target_amount) - Number(dashboard.target.actual_amount))) }} 达成年度目标
|
||||
</p>
|
||||
<p v-else class="target-empty">当前范围未维护可用目标,实际业绩仍按统一口径正常统计。</p>
|
||||
</div>
|
||||
<div class="target-chart-wrap">
|
||||
<div class="chart-legend"><i class="actual" />累计完成 <i class="target" />累计目标</div>
|
||||
<v-charts class="target-chart" :option="targetChartOption" autoresize />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>{{ dashboard.meta.time_label || '今日' }}业绩 TOP</h2><p>按业务订单创建人排序</p></div>
|
||||
<span class="panel-badge">金额</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.performance.length" class="ranking-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.performance" :key="`performance-${item.admin_id}`" class="ranking-row">
|
||||
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
|
||||
<span>{{ item.name }}<small>{{ item.count }} 单</small></span>
|
||||
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxPerformance) }" /></div>
|
||||
<strong>{{ formatMoney(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="暂无业绩数据" />
|
||||
</article>
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>{{ dashboard.meta.time_label || '今日' }}挂号 TOP</h2><p>按有效挂号数量排序</p></div>
|
||||
<span class="panel-badge">挂号</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.appointments.length" class="ranking-list ranking-list--blue">
|
||||
<div v-for="(item, index) in dashboard.rankings.appointments" :key="`appointment-${item.admin_id}`" class="ranking-row">
|
||||
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
|
||||
<span>{{ item.name }}<small>有效预约</small></span>
|
||||
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxAppointments) }" /></div>
|
||||
<strong>{{ formatNumber(item.value) }} 个</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="暂无挂号数据" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel department-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>按部门统计</h2><p>员工按最深层有效归属部门唯一计入,避免多部门重复累计</p></div>
|
||||
<span class="panel-badge">{{ dashboard.departments.length }} 个部门</span>
|
||||
</div>
|
||||
<el-table :data="dashboard.departments" class="stats-table department-table">
|
||||
<el-table-column prop="name" label="部门" min-width="220">
|
||||
<template #default="{ row }"><strong>{{ row.name }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="member_count" label="人数" min-width="90" align="right" sortable />
|
||||
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
|
||||
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
|
||||
<el-table-column prop="order_count" label="诊单" min-width="90" align="right" sortable />
|
||||
<el-table-column label="业绩" min-width="130" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号环比" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
|
||||
{{ compactCompare(row.appointment_compare_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前范围暂无部门汇总" /></template>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<footer class="data-note">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ dashboard.meta.appointment_rule }};{{ dashboard.meta.performance_rule }}。</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitRegistrationStatsPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Calendar,
|
||||
DocumentChecked,
|
||||
Histogram,
|
||||
InfoFilled,
|
||||
Lock,
|
||||
Refresh,
|
||||
Wallet
|
||||
} from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import {
|
||||
firstVisitRegistrationStatsOverview,
|
||||
type FirstVisitRegistrationStatsParams
|
||||
} from '@/api/first_visit'
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
member_count: 0, appointment_rule: '', performance_rule: ''
|
||||
},
|
||||
filters: { departments: [] as any[], assistants: [] as Array<{ id: number; name: string }> },
|
||||
summary: {
|
||||
appointment_count: 0, appointment_compare_count: 0, appointment_compare_rate: null as number | null,
|
||||
order_count: 0, order_amount: 0, range_label: '今日'
|
||||
},
|
||||
employee_rows: [] as any[],
|
||||
rankings: { performance: [] as any[], appointments: [] as any[] },
|
||||
departments: [] as any[],
|
||||
target: {
|
||||
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0,
|
||||
completion_rate: null as number | null, department_count: 0, scope_note: '',
|
||||
months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const dashboard = reactive(emptyDashboard())
|
||||
const query = reactive<FirstVisitRegistrationStatsParams>({ time_type: 'today' })
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' }
|
||||
]
|
||||
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
|
||||
|
||||
const maxPerformance = computed(() => Math.max(0, ...dashboard.rankings.performance.map((item: any) => Number(item.value) || 0)))
|
||||
const maxAppointments = computed(() => Math.max(0, ...dashboard.rankings.appointments.map((item: any) => Number(item.value) || 0)))
|
||||
const targetChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
grid: { left: 18, right: 18, top: 18, bottom: 12, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
valueFormatter: (value: number) => formatMoney(value)
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: dashboard.target.months,
|
||||
axisLine: { lineStyle: { color: '#d9e1e8' } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#7d8b9c', fontSize: 11 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitNumber: 3,
|
||||
axisLabel: { color: '#8b98a8', formatter: (value: number) => compactMoney(value) },
|
||||
splitLine: { lineStyle: { color: '#eef2f5' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '累计完成', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5,
|
||||
data: dashboard.target.actual_cumulative,
|
||||
lineStyle: { color: '#139a8c', width: 3 },
|
||||
itemStyle: { color: '#ffffff', borderColor: '#139a8c', borderWidth: 2 },
|
||||
areaStyle: { color: 'rgba(19,154,140,.08)' }
|
||||
},
|
||||
{
|
||||
name: '累计目标', type: 'line', smooth: true, showSymbol: false,
|
||||
data: dashboard.target.target_cumulative,
|
||||
lineStyle: { color: '#5f86e8', width: 2, type: 'dashed' }
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: FirstVisitRegistrationStatsParams = { time_type: query.time_type }
|
||||
if (query.dept_id) params.dept_id = Number(query.dept_id)
|
||||
if (query.assistant_id) params.assistant_id = Number(query.assistant_id)
|
||||
const result: any = await firstVisitRegistrationStatsOverview(params)
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '挂号统计加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDepartmentChange() {
|
||||
delete query.assistant_id
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown) {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function compactMoney(value: number) {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
function nullablePercent(value: unknown) {
|
||||
return value === null || value === undefined ? '未设置' : `${Number(value).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function progressValue(value: unknown) {
|
||||
return Math.min(100, Math.max(0, Number(value) || 0))
|
||||
}
|
||||
|
||||
function compareText(value: unknown) {
|
||||
if (value === null || value === undefined) return '上期无数据,暂不计算环比'
|
||||
const number = Number(value)
|
||||
if (number === 0) return '与上期持平'
|
||||
return `${number > 0 ? '较上期增长' : '较上期下降'} ${Math.abs(number).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function compactCompare(value: unknown) {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const number = Number(value)
|
||||
return `${number > 0 ? '+' : ''}${number.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function compareClass(value: unknown) {
|
||||
if (value === null || value === undefined || Number(value) === 0) return 'is-neutral'
|
||||
return Number(value) > 0 ? 'is-up' : 'is-down'
|
||||
}
|
||||
|
||||
function rankWidth(value: unknown, max: number) {
|
||||
if (max <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, Number(value || 0) / max * 100))}%`
|
||||
}
|
||||
|
||||
function avatarText(name: unknown) {
|
||||
const text = String(name || '员').trim()
|
||||
return text.slice(-1)
|
||||
}
|
||||
|
||||
function sortAmount(left: any, right: any) {
|
||||
return Number(left?.order_amount || 0) - Number(right?.order_amount || 0)
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.registration-stats {
|
||||
--ink: #17243a;
|
||||
--muted: #778598;
|
||||
--line: #e3e9ef;
|
||||
--canvas: #f4f6f8;
|
||||
--teal: #139a8c;
|
||||
--blue: #4f78e5;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.panel,
|
||||
.metric-card {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
min-height: 76px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.heading-copy,
|
||||
.heading-actions,
|
||||
.filter-item,
|
||||
.name-cell,
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heading-copy { gap: 12px; }
|
||||
.heading-mark {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
background: var(--teal);
|
||||
box-shadow: 0 8px 20px rgba(19, 154, 140, .18);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: 20px; line-height: 1.3; letter-spacing: .01em; }
|
||||
h2 { font-size: 15px; line-height: 1.4; }
|
||||
.heading-copy p, .panel-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.heading-actions { gap: 12px; color: var(--muted); font-size: 12px; }
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid #cbe8e3;
|
||||
border-radius: 7px;
|
||||
color: #127f75;
|
||||
background: #f1faf8;
|
||||
}
|
||||
|
||||
.filter-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
margin-top: 14px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.filter-item { gap: 9px; color: #68778b; font-size: 13px; }
|
||||
.dept-select { width: 220px; }
|
||||
.employee-select { width: 180px; }
|
||||
.filter-summary { margin-left: auto; text-align: right; }
|
||||
.filter-summary strong { display: block; font-size: 13px; }
|
||||
.filter-summary span { color: var(--muted); font-size: 11px; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
min-height: 114px;
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.metric-icon {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.metric-card--teal .metric-icon { color: #107f75; background: #e9f7f5; }
|
||||
.metric-card--blue .metric-icon { color: #416bd7; background: #edf1ff; }
|
||||
.metric-card--amber .metric-icon { color: #bc7428; background: #fff4e7; }
|
||||
.metric-card span { display: block; color: #748296; font-size: 13px; }
|
||||
.metric-card strong { display: block; margin: 4px 0 2px; font-size: 27px; line-height: 1.15; }
|
||||
.metric-card small { color: #8b98a8; font-size: 11px; }
|
||||
.metric-card small.is-up, .rate-text.is-up { color: #17956f; }
|
||||
.metric-card small.is-down, .rate-text.is-down { color: #e25858; }
|
||||
|
||||
.panel { margin-top: 14px; border-radius: 12px; overflow: hidden; }
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 15px 16px 13px;
|
||||
}
|
||||
.panel-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
color: #758497;
|
||||
background: #f2f5f7;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stats-table { --el-table-header-bg-color: #f7f9fb; --el-table-border-color: #e7ecf1; }
|
||||
.stats-table :deep(th.el-table__cell) { height: 42px; color: #68778b; font-weight: 500; }
|
||||
.stats-table :deep(td.el-table__cell) { height: 47px; }
|
||||
.stats-table :deep(.el-table__row--level-0) { background: #fafcfd; }
|
||||
.name-cell { gap: 9px; }
|
||||
.name-cell em { color: #8d99a7; font-size: 11px; font-style: normal; font-weight: 400; }
|
||||
.name-cell.is-department strong { font-weight: 700; }
|
||||
.name-cell :deep(.el-avatar) { color: #167e75; background: #e8f5f3; font-size: 11px; }
|
||||
.dept-dot { width: 8px; height: 8px; border-radius: 3px; background: var(--teal); }
|
||||
.status-pill { display: inline-flex; align-items: center; gap: 5px; color: #218d71; font-size: 12px; }
|
||||
.status-pill i { width: 6px; height: 6px; border-radius: 50%; background: #34b38d; }
|
||||
.rate-text { font-size: 12px; }
|
||||
.rate-text.is-neutral { color: #8b98a8; }
|
||||
|
||||
.target-layout { display: grid; grid-template-columns: minmax(340px, .8fr) minmax(480px, 1.2fr); border-top: 1px solid #edf1f4; }
|
||||
.target-summary { display: flex; flex-direction: column; justify-content: center; padding: 24px 22px; border-right: 1px solid #edf1f4; }
|
||||
.target-numbers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-bottom: 22px; }
|
||||
.target-numbers span { display: block; color: var(--muted); font-size: 12px; }
|
||||
.target-numbers strong { display: block; margin-top: 5px; font-size: 20px; }
|
||||
.target-numbers .is-teal { color: var(--teal); }
|
||||
.target-summary p { margin-top: 9px; color: var(--muted); font-size: 11px; }
|
||||
.target-summary .target-empty { color: #b17832; }
|
||||
.target-chart-wrap { position: relative; min-height: 238px; padding: 10px 14px 4px; }
|
||||
.target-chart { width: 100%; height: 225px; }
|
||||
.chart-legend { position: absolute; z-index: 2; top: 11px; right: 18px; gap: 6px; color: #778598; font-size: 11px; }
|
||||
.chart-legend i { width: 18px; height: 3px; margin-left: 8px; border-radius: 2px; }
|
||||
.chart-legend .actual { background: var(--teal); }
|
||||
.chart-legend .target { background: repeating-linear-gradient(90deg, var(--blue) 0 5px, transparent 5px 8px); }
|
||||
|
||||
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.ranking-panel { min-height: 286px; }
|
||||
.ranking-list { padding: 2px 16px 16px; }
|
||||
.ranking-row { display: grid; grid-template-columns: 30px minmax(110px, .8fr) minmax(100px, 1fr) 105px; align-items: center; gap: 10px; min-height: 43px; border-top: 1px solid #eff2f5; }
|
||||
.ranking-row > b { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 7px; color: #8491a1; background: #f1f4f6; font-size: 11px; }
|
||||
.ranking-row > b.is-top { color: #fff; background: var(--teal); }
|
||||
.ranking-list--blue .ranking-row > b.is-top { background: var(--blue); }
|
||||
.ranking-row > span { overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ranking-row small { margin-left: 6px; color: #939eab; font-size: 10px; font-weight: 400; }
|
||||
.ranking-row > strong { text-align: right; font-size: 12px; }
|
||||
.rank-track { height: 7px; overflow: hidden; border-radius: 5px; background: #edf1f4; }
|
||||
.rank-track i { display: block; height: 100%; border-radius: inherit; background: var(--teal); }
|
||||
.ranking-list--blue .rank-track i { background: var(--blue); }
|
||||
.department-table { border-top: 1px solid #edf1f4; }
|
||||
.data-note { display: flex; align-items: center; gap: 6px; padding: 11px 2px 2px; color: #8b97a6; font-size: 11px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.filter-summary { margin-left: 0; }
|
||||
.target-layout { grid-template-columns: 1fr; }
|
||||
.target-summary { border-right: 0; border-bottom: 1px solid #edf1f4; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.registration-stats { padding: 10px; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; }
|
||||
.heading-actions { flex-wrap: wrap; }
|
||||
.update-time { display: none; }
|
||||
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
|
||||
.filter-item { width: 100%; justify-content: space-between; }
|
||||
.dept-select, .employee-select { width: calc(100% - 52px); }
|
||||
.filter-item--time { justify-content: flex-start; }
|
||||
.target-numbers { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,589 @@
|
||||
<template>
|
||||
<div class="promotion-page" v-loading="loading" element-loading-text="正在加载推广配置">
|
||||
<header class="page-header">
|
||||
<div class="heading-copy">
|
||||
<span class="heading-icon"><el-icon><Promotion /></el-icon></span>
|
||||
<div>
|
||||
<h1>企业微信推广助手</h1>
|
||||
<p>授权企业微信后,将多个推广链接按权重、时段和每日上限安全分流</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ overview.meta.scope_label || '当前数据范围' }}</span>
|
||||
<span v-if="overview.meta.generated_at" class="update-time">更新于 {{ overview.meta.generated_at }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadOverview">刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<span class="metric-icon is-teal"><el-icon><OfficeBuilding /></el-icon></span>
|
||||
<div><small>已授权企业</small><strong>{{ overview.summary.authorized_accounts }}</strong><p>凭证仅在服务端加密保存</p></div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-icon is-blue"><el-icon><SetUp /></el-icon></span>
|
||||
<div><small>分流方案</small><strong>{{ overview.summary.pool_count }}</strong><p>每个方案生成独立 JS</p></div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-icon is-green"><el-icon><Link /></el-icon></span>
|
||||
<div><small>在线链接</small><strong>{{ overview.summary.online_links }}</strong><p>停用或超限自动排除</p></div>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-icon is-orange"><el-icon><Mouse /></el-icon></span>
|
||||
<div><small>今日分流点击</small><strong>{{ overview.summary.today_clicks }}</strong><p>只记录脱敏访问数据</p></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="workspace-card">
|
||||
<nav class="tab-nav" aria-label="企业微信推广助手功能">
|
||||
<button :class="{ active: activeTab === 'links' }" @click="activeTab = 'links'">
|
||||
<el-icon><Connection /></el-icon>推广链接
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'authorization' }" @click="activeTab = 'authorization'">
|
||||
<el-icon><Key /></el-icon>企业授权
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'install' }" @click="activeTab = 'install'">
|
||||
<el-icon><DocumentCopy /></el-icon>JS 安装
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div v-if="activeTab === 'links'" class="tab-content links-tab">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>推广链接分流</h2>
|
||||
<p>访客点击时由服务端从当前可用链接中按权重随机选择,页面端不会暴露完整链接池。</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="overview.pools.length" class="pool-layout">
|
||||
<aside class="pool-sidebar">
|
||||
<button
|
||||
v-for="pool in overview.pools"
|
||||
:key="pool.id"
|
||||
class="pool-item"
|
||||
:class="{ active: Number(pool.id) === selectedPoolId }"
|
||||
@click="selectedPoolId = Number(pool.id)"
|
||||
>
|
||||
<span class="pool-status" :class="Number(pool.status) === 1 ? 'online' : 'offline'" />
|
||||
<span><strong>{{ pool.name }}</strong><small>{{ linkCount(pool.id) }} 条链接 · {{ formatNumber(pool.click_count) }} 次点击</small></span>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div class="pool-main">
|
||||
<div v-if="selectedPool" class="pool-toolbar">
|
||||
<div>
|
||||
<div class="pool-title-row">
|
||||
<h3>{{ selectedPool.name }}</h3>
|
||||
<span class="status-tag" :class="Number(selectedPool.status) === 1 ? 'is-online' : 'is-offline'">
|
||||
{{ Number(selectedPool.status) === 1 ? '运行中' : '已停用' }}
|
||||
</span>
|
||||
<span class="owner-tag">{{ selectedPool.dept_name || '未分部门' }} · {{ selectedPool.owner_name || '系统' }}</span>
|
||||
</div>
|
||||
<p>公开键:{{ selectedPool.public_key }}</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
|
||||
<el-button :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" @click="removePool(selectedPool)">删除</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openLinkDialog()">添加推广链接</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="selectedLinks" class="link-table" stripe>
|
||||
<el-table-column label="推广成员 / 分组" min-width="180" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="member-cell"><strong>{{ row.name }}</strong><small>{{ row.group_name || '默认分组' }}</small></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="授权企业" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.account_id">{{ row.corp_name || '授权已失效' }}</span>
|
||||
<span v-else class="muted">未关联</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权重" prop="weight" width="84" align="center" />
|
||||
<el-table-column label="今日 / 上限" min-width="120" align="center">
|
||||
<template #default="{ row }">{{ todayCount(row) }} / {{ Number(row.daily_limit) ? row.daily_limit : '不限' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="累计点击" prop="click_count" min-width="105" align="center" />
|
||||
<el-table-column label="当前可用性" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="availability" :class="eligibility(row).className">{{ eligibility(row).label }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上线" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="Number(row.status) === 1" @change="(value) => handleToggle(row, value)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="132" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openLinkDialog(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="removeLink(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :image-size="72" description="当前方案还没有推广链接" /></template>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="先创建一个分流方案,再添加企业微信推广链接">
|
||||
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">创建第一个方案</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'authorization'" class="tab-content authorization-tab">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>企业微信第三方应用授权</h2>
|
||||
<p>永久授权码、suite_ticket 等敏感凭证全部加密存放,前端接口只返回企业名称和脱敏 CorpID。</p>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="overview.meta.can_authorize"
|
||||
type="primary"
|
||||
:icon="CirclePlus"
|
||||
:loading="authorizing"
|
||||
:disabled="!overview.config.ready"
|
||||
@click="startAuthorization"
|
||||
>授权新企业</el-button>
|
||||
</div>
|
||||
|
||||
<div class="configuration-panel" :class="overview.config.ready ? 'is-ready' : 'is-pending'">
|
||||
<div class="configuration-state">
|
||||
<span><el-icon><component :is="overview.config.ready ? CircleCheck : Warning" /></el-icon></span>
|
||||
<div>
|
||||
<strong>{{ overview.config.ready ? '授权通道已就绪' : '授权通道等待配置' }}</strong>
|
||||
<p v-if="overview.config.ready">已收到企业微信 suite_ticket,可以发起第三方应用安装授权。</p>
|
||||
<p v-else-if="overview.config.configured">配置已保存,尚未收到 suite_ticket,请核对应用指令回调地址。</p>
|
||||
<p v-else>请由运维按部署文档补齐服务商参数;页面不会展示任何密钥值。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="overview.config.missing?.length" class="missing-fields">
|
||||
缺少:<code v-for="item in overview.config.missing" :key="item">{{ item }}</code>
|
||||
</div>
|
||||
<dl class="callback-list">
|
||||
<div><dt>应用指令回调</dt><dd>{{ overview.config.provider_callback_url || '—' }}</dd><button @click="copyText(overview.config.provider_callback_url, '应用指令回调')">复制</button></div>
|
||||
<div><dt>授权完成回调</dt><dd>{{ overview.config.auth_callback_url || '—' }}</dd><button @click="copyText(overview.config.auth_callback_url, '授权完成回调')">复制</button></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<el-table :data="overview.accounts" class="account-table" stripe>
|
||||
<el-table-column prop="corp_name" label="企业微信名称" min-width="180" fixed="left">
|
||||
<template #default="{ row }"><strong>{{ row.corp_name }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="corp_id_masked" label="CorpID(脱敏)" min-width="190" />
|
||||
<el-table-column prop="agent_id" label="应用 AgentID" min-width="130">
|
||||
<template #default="{ row }">{{ row.agent_id || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="授权状态" min-width="110">
|
||||
<template #default="{ row }"><span class="availability" :class="Number(row.auth_status) === 1 ? 'is-ok' : 'is-error'">{{ Number(row.auth_status) === 1 ? '有效' : '已取消' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="授权时间" min-width="170"><template #default="{ row }">{{ formatTime(row.authorized_at) }}</template></el-table-column>
|
||||
<el-table-column label="最近校验" min-width="170"><template #default="{ row }">{{ formatTime(row.last_refresh_at) }}</template></el-table-column>
|
||||
<el-table-column v-if="overview.meta.can_authorize" label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }"><el-button type="primary" link :loading="verifyingId === Number(row.id)" @click="verifyAccount(row)">验证凭证</el-button></template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :image-size="72" description="尚未授权企业微信" /></template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div v-else class="tab-content install-tab">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>安装 JS 到推广落地页</h2>
|
||||
<p>基础脚本只负责捕获点击并请求服务端分流,不携带授权凭证,也不在浏览器中计算随机规则。</p>
|
||||
</div>
|
||||
<el-select v-model="selectedInstallPoolId" placeholder="选择分流方案" class="install-pool-select">
|
||||
<el-option v-for="pool in overview.pools" :key="pool.id" :label="pool.name" :value="Number(pool.id)" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<template v-if="selectedInstallPool">
|
||||
<div class="install-grid">
|
||||
<article class="code-card">
|
||||
<div class="code-heading"><span>1</span><div><strong>安装基础脚本</strong><p>放在页面 <code></head></code> 标签之前,只需安装一次。</p></div></div>
|
||||
<pre><code>{{ selectedInstallPool.install_code }}</code></pre>
|
||||
<el-button type="primary" plain :icon="DocumentCopy" @click="copyText(selectedInstallPool.install_code, '基础脚本')">复制代码</el-button>
|
||||
</article>
|
||||
<article class="code-card">
|
||||
<div class="code-heading"><span>2</span><div><strong>标记点击元素</strong><p>按钮、图片或文字链接都可以使用同一个数据属性。</p></div></div>
|
||||
<pre><code>{{ selectedInstallPool.trigger_code }}</code></pre>
|
||||
<el-button type="primary" plain :icon="DocumentCopy" @click="copyText(selectedInstallPool.trigger_code, '点击元素代码')">复制代码</el-button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="rule-panel">
|
||||
<div><el-icon><Select /></el-icon><span><strong>可用性筛选</strong>自动排除停用、授权失效、超出时间段和达到每日上限的链接。</span></div>
|
||||
<div><el-icon><Opportunity /></el-icon><span><strong>权重随机</strong>权重越高,被选中的概率越大;没有可用链接时才使用兜底链接。</span></div>
|
||||
<div><el-icon><View /></el-icon><span><strong>隐私与安全</strong>前端看不到完整链接池;访问 IP 只保存带服务端密钥的不可逆哈希。</span></div>
|
||||
</div>
|
||||
|
||||
<div class="test-row">
|
||||
<div><strong>分流测试</strong><p>每次打开都会执行与线上相同的筛选和随机规则,并计入点击数据。</p></div>
|
||||
<el-button type="primary" :icon="TopRight" @click="openTestLink">打开测试链接</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="请先创建分流方案,系统会自动生成 JS 安装代码" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="poolDialogVisible" :title="poolForm.id ? '编辑分流方案' : '新建分流方案'" width="560px" destroy-on-close>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="方案名称" required><el-input v-model="poolForm.name" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" /></el-form-item>
|
||||
<el-form-item label="兜底企业微信链接">
|
||||
<el-input v-model="poolForm.fallback_url" placeholder="可选;没有可用成员时跳转" />
|
||||
<span class="form-tip">仅允许配置页列出的企业微信 HTTPS 域名。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="运行状态"><el-switch v-model="poolForm.status" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">保存方案</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="linkDialogVisible" :title="linkForm.id ? '编辑推广链接' : '添加推广链接'" width="680px" destroy-on-close>
|
||||
<el-form label-position="top" class="link-form">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="推广成员名称" required><el-input v-model="linkForm.name" maxlength="80" placeholder="用于内部识别" /></el-form-item>
|
||||
<el-form-item label="分组"><el-input v-model="linkForm.group_name" maxlength="60" placeholder="默认分组" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="企业微信推广链接" required>
|
||||
<el-input v-model="linkForm.wecom_url" type="textarea" :rows="2" placeholder="粘贴企业微信成员联系我、客户群或其他允许的推广链接" />
|
||||
<span class="form-tip">允许域名:{{ overview.allowed_link_hosts.join('、') || '请先配置允许域名' }}</span>
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="关联授权企业">
|
||||
<el-select v-model="linkForm.account_id" clearable placeholder="可选;用于授权失效联动下线">
|
||||
<el-option v-for="account in activeAccounts" :key="account.id" :label="account.corp_name" :value="Number(account.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分流权重" required><el-input-number v-model="linkForm.weight" :min="1" :max="100" controls-position="right" /></el-form-item>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="每日点击上限"><el-input-number v-model="linkForm.daily_limit" :min="0" :max="1000000" controls-position="right" /><span class="form-tip">0 表示不限</span></el-form-item>
|
||||
<el-form-item label="上线状态"><el-switch v-model="linkForm.status" :active-value="1" :inactive-value="0" active-text="上线" inactive-text="下线" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="生效时间段">
|
||||
<el-date-picker v-model="linkForm.active_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间" end-placeholder="结束时间" range-separator="至" />
|
||||
<span class="form-tip">不选择表示长期有效</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="linkForm.remark" maxlength="255" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="linkDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingLink" @click="saveLink">保存链接</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitWecomPromotionPage">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowRight, CircleCheck, CirclePlus, Connection, Delete, DocumentCopy, Edit, Key, Link,
|
||||
Lock, Mouse, OfficeBuilding, Opportunity, Plus, Promotion, Refresh, Select, SetUp,
|
||||
TopRight, View, Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
wecomPromotionAuthorizationUrl,
|
||||
wecomPromotionDeleteLink,
|
||||
wecomPromotionDeletePool,
|
||||
wecomPromotionOverview,
|
||||
wecomPromotionSaveLink,
|
||||
wecomPromotionSavePool,
|
||||
wecomPromotionToggleLink,
|
||||
wecomPromotionVerifyAccount
|
||||
} from '@/api/first_visit'
|
||||
|
||||
type TabName = 'links' | 'authorization' | 'install'
|
||||
const emptyOverview = () => ({
|
||||
meta: { scope_label: '', can_authorize: false, generated_at: '' },
|
||||
config: { enabled: false, configured: false, ready: false, missing: [] as string[], suite_id_masked: '', ticket_received_at: 0, provider_callback_url: '', auth_callback_url: '' },
|
||||
summary: { authorized_accounts: 0, pool_count: 0, online_links: 0, today_clicks: 0 },
|
||||
accounts: [] as any[], pools: [] as any[], links: [] as any[], allowed_link_hosts: [] as string[]
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const overview = reactive(emptyOverview())
|
||||
const loading = ref(false)
|
||||
const activeTab = ref<TabName>('links')
|
||||
const selectedPoolId = ref<number>()
|
||||
const selectedInstallPoolId = ref<number>()
|
||||
const authorizing = ref(false)
|
||||
const verifyingId = ref(0)
|
||||
const poolDialogVisible = ref(false)
|
||||
const linkDialogVisible = ref(false)
|
||||
const savingPool = ref(false)
|
||||
const savingLink = ref(false)
|
||||
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1 })
|
||||
const linkForm = reactive({ id: 0, pool_id: 0, account_id: undefined as number | undefined, name: '', group_name: '默认分组', wecom_url: '', weight: 1, status: 1, daily_limit: 0, active_range: [] as string[], remark: '' })
|
||||
|
||||
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
|
||||
const selectedLinks = computed(() => overview.links.filter((item: any) => Number(item.pool_id) === selectedPoolId.value))
|
||||
const activeAccounts = computed(() => overview.accounts.filter((item: any) => Number(item.auth_status) === 1))
|
||||
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
|
||||
|
||||
watch(() => overview.pools.map((item: any) => Number(item.id)).join(','), () => {
|
||||
const ids = overview.pools.map((item: any) => Number(item.id))
|
||||
if (!selectedPoolId.value || !ids.includes(selectedPoolId.value)) selectedPoolId.value = ids[0]
|
||||
if (!selectedInstallPoolId.value || !ids.includes(selectedInstallPoolId.value)) selectedInstallPoolId.value = ids[0]
|
||||
}, { immediate: true })
|
||||
|
||||
async function loadOverview() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: any = await wecomPromotionOverview()
|
||||
Object.assign(overview, emptyOverview(), result || {})
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '企业微信推广配置加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function linkCount(poolId: number) {
|
||||
return overview.links.filter((item: any) => Number(item.pool_id) === Number(poolId)).length
|
||||
}
|
||||
|
||||
function openPoolDialog(pool?: any) {
|
||||
Object.assign(poolForm, pool ? { id: Number(pool.id), name: pool.name, fallback_url: pool.fallback_url || '', status: Number(pool.status) } : { id: 0, name: '', fallback_url: '', status: 1 })
|
||||
poolDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function savePool() {
|
||||
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
|
||||
savingPool.value = true
|
||||
try {
|
||||
const result: any = await wecomPromotionSavePool({ ...poolForm })
|
||||
poolDialogVisible.value = false
|
||||
await loadOverview()
|
||||
if (result?.id) selectedPoolId.value = Number(result.id)
|
||||
ElMessage.success('分流方案已保存')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '分流方案保存失败')
|
||||
} finally {
|
||||
savingPool.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePool(pool: any) {
|
||||
await ElMessageBox.confirm(`删除“${pool.name}”后,该方案下的推广链接也会停用。确认继续?`, '删除分流方案', { type: 'warning' })
|
||||
try {
|
||||
await wecomPromotionDeletePool({ id: Number(pool.id) })
|
||||
ElMessage.success('分流方案已删除')
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openLinkDialog(row?: any) {
|
||||
if (!selectedPool.value) return
|
||||
const range = row && Number(row.active_start) > 0 && Number(row.active_end) > 0
|
||||
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)] : []
|
||||
Object.assign(linkForm, row ? {
|
||||
id: Number(row.id), pool_id: Number(row.pool_id), account_id: Number(row.account_id) || undefined,
|
||||
name: row.name, group_name: row.group_name || '默认分组', wecom_url: row.wecom_url,
|
||||
weight: Number(row.weight) || 1, status: Number(row.status), daily_limit: Number(row.daily_limit) || 0,
|
||||
active_range: range, remark: row.remark || ''
|
||||
} : {
|
||||
id: 0, pool_id: Number(selectedPool.value.id), account_id: undefined, name: '', group_name: '默认分组',
|
||||
wecom_url: '', weight: 1, status: 1, daily_limit: 0, active_range: [], remark: ''
|
||||
})
|
||||
linkDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveLink() {
|
||||
if (!linkForm.name.trim()) return ElMessage.warning('请输入推广成员名称')
|
||||
if (!linkForm.wecom_url.trim()) return ElMessage.warning('请粘贴企业微信推广链接')
|
||||
savingLink.value = true
|
||||
try {
|
||||
await wecomPromotionSaveLink({
|
||||
...linkForm,
|
||||
account_id: linkForm.account_id || 0,
|
||||
active_start: linkForm.active_range?.[0] || '',
|
||||
active_end: linkForm.active_range?.[1] || ''
|
||||
})
|
||||
linkDialogVisible.value = false
|
||||
await loadOverview()
|
||||
ElMessage.success('推广链接已保存')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '推广链接保存失败')
|
||||
} finally {
|
||||
savingLink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(row: any, value: unknown) {
|
||||
try {
|
||||
await wecomPromotionToggleLink({ id: Number(row.id), status: value ? 1 : 0 })
|
||||
row.status = value ? 1 : 0
|
||||
ElMessage.success(value ? '链接已上线' : '链接已下线')
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '状态更新失败')
|
||||
await loadOverview()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLink(row: any) {
|
||||
await ElMessageBox.confirm(`确认删除推广链接“${row.name}”?`, '删除推广链接', { type: 'warning' })
|
||||
try {
|
||||
await wecomPromotionDeleteLink({ id: Number(row.id) })
|
||||
ElMessage.success('推广链接已删除')
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function startAuthorization() {
|
||||
authorizing.value = true
|
||||
try {
|
||||
const result: any = await wecomPromotionAuthorizationUrl()
|
||||
if (!result?.url) throw new Error('未获取到企业微信授权地址')
|
||||
window.location.assign(result.url)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '授权地址生成失败')
|
||||
authorizing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAccount(row: any) {
|
||||
verifyingId.value = Number(row.id)
|
||||
try {
|
||||
await wecomPromotionVerifyAccount({ id: Number(row.id) })
|
||||
ElMessage.success('授权凭证有效')
|
||||
await loadOverview()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '凭证验证失败')
|
||||
} finally {
|
||||
verifyingId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function eligibility(row: any) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (Number(row.status) !== 1) return { label: '已下线', className: 'is-muted' }
|
||||
if (Number(row.account_id) > 0 && Number(row.auth_status) !== 1) return { label: '授权失效', className: 'is-error' }
|
||||
if (Number(row.active_start) > 0 && Number(row.active_start) > now) return { label: '尚未生效', className: 'is-waiting' }
|
||||
if (Number(row.active_end) > 0 && Number(row.active_end) < now) return { label: '已过期', className: 'is-error' }
|
||||
if (Number(row.daily_limit) > 0 && todayCount(row) >= Number(row.daily_limit)) return { label: '今日已达上限', className: 'is-waiting' }
|
||||
return { label: '可参与分流', className: 'is-ok' }
|
||||
}
|
||||
|
||||
function todayCount(row: any) {
|
||||
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
|
||||
return row.today_date === today ? Number(row.today_count || 0) : 0
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown) {
|
||||
return Number(value || 0).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function formatTime(value: unknown) {
|
||||
const timestamp = Number(value || 0)
|
||||
if (!timestamp) return '—'
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function formatPickerTime(value: unknown) {
|
||||
const date = new Date(Number(value || 0) * 1000)
|
||||
const pad = (number: number) => String(number).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
async function copyText(value: string, label: string) {
|
||||
if (!value) return ElMessage.warning(`${label}暂无内容`)
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
textarea.remove()
|
||||
}
|
||||
ElMessage.success(`${label}已复制`)
|
||||
}
|
||||
|
||||
function openTestLink() {
|
||||
if (selectedInstallPool.value?.go_url) window.open(selectedInstallPool.value.go_url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function handleAuthorizationResult() {
|
||||
const status = String(route.query.wecom_auth || '')
|
||||
if (!status) return
|
||||
activeTab.value = 'authorization'
|
||||
if (status === 'success') ElMessage.success('企业微信授权成功,凭证已安全保存')
|
||||
else ElMessage.error(String(route.query.message || '企业微信授权失败,请重新发起'))
|
||||
const query = { ...route.query }
|
||||
delete query.wecom_auth
|
||||
delete query.account_id
|
||||
delete query.message
|
||||
await router.replace({ query })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOverview()
|
||||
await handleAuthorizationResult()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.promotion-page {
|
||||
--ink: #17243a;
|
||||
--muted: #778598;
|
||||
--line: #e1e7ed;
|
||||
--canvas: #f4f6f8;
|
||||
--teal: #139a8c;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
}
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
.page-header, .metric-card, .workspace-card { border: 1px solid var(--line); background: #fff; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 78px; padding: 14px 18px; border-radius: 13px; }
|
||||
.heading-copy, .heading-actions, .pool-title-row, .toolbar-actions { display: flex; align-items: center; }
|
||||
.heading-copy { gap: 12px; }
|
||||
.heading-icon { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 11px; color: #fff; background: var(--teal); font-size: 21px; box-shadow: 0 8px 20px rgba(19,154,140,.18); }
|
||||
.heading-copy h1 { font-size: 20px; }
|
||||
.heading-copy p { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.heading-actions { gap: 10px; color: var(--muted); font-size: 12px; }
|
||||
.scope-chip { display: inline-flex; align-items: center; gap: 5px; padding: 6px 9px; border: 1px solid #cce8e3; border-radius: 7px; color: #117f75; background: #f2faf8; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 14px; }
|
||||
.metric-card { display: flex; align-items: center; gap: 13px; min-height: 104px; padding: 16px; border-radius: 11px; }
|
||||
.metric-icon { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border-radius: 11px; font-size: 20px; }
|
||||
.metric-icon.is-teal { color: #0e8b7e; background: #e8f7f4; }.metric-icon.is-blue { color: #3976e6; background: #edf3ff; }.metric-icon.is-green { color: #3b9c67; background: #edf8f1; }.metric-icon.is-orange { color: #d87a2d; background: #fff2e7; }
|
||||
.metric-card small { color: #78869a; font-size: 12px; }.metric-card strong { display: block; margin: 3px 0; font-size: 25px; line-height: 1.15; }.metric-card p { color: #98a2af; font-size: 10px; }
|
||||
.workspace-card { margin-top: 14px; border-radius: 13px; overflow: hidden; }
|
||||
.tab-nav { display: flex; gap: 4px; padding: 0 18px; border-bottom: 1px solid var(--line); background: #fbfcfd; }
|
||||
.tab-nav button { display: flex; align-items: center; gap: 7px; min-width: 132px; height: 54px; justify-content: center; border: 0; border-bottom: 3px solid transparent; color: #5e6d80; background: transparent; cursor: pointer; font-size: 14px; }
|
||||
.tab-nav button.active { border-bottom-color: var(--teal); color: #0d8277; font-weight: 700; }
|
||||
.tab-content { min-height: 460px; padding: 20px; }
|
||||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
|
||||
.section-heading h2 { font-size: 17px; }.section-heading p { margin-top: 5px; color: var(--muted); font-size: 12px; }
|
||||
.pool-layout { display: grid; grid-template-columns: 252px minmax(0, 1fr); min-height: 430px; border: 1px solid var(--line); border-radius: 11px; overflow: hidden; }
|
||||
.pool-sidebar { padding: 8px; border-right: 1px solid var(--line); background: #f7f9fa; }
|
||||
.pool-item { display: grid; grid-template-columns: 9px 1fr 16px; align-items: center; gap: 9px; width: 100%; min-height: 62px; margin-bottom: 5px; padding: 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; background: transparent; cursor: pointer; }
|
||||
.pool-item:hover { background: #fff; }.pool-item.active { border-color: #bfe1dc; background: #fff; box-shadow: 0 5px 16px rgba(25,69,70,.05); }
|
||||
.pool-item strong, .pool-item small { display: block; }.pool-item strong { overflow: hidden; color: #253348; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.pool-item small { margin-top: 4px; color: #8c98a7; font-size: 10px; }
|
||||
.pool-status { width: 8px; height: 8px; border-radius: 50%; }.pool-status.online { background: #18a277; }.pool-status.offline { background: #aab3bf; }.pool-item > .el-icon { color: #9ca7b4; }
|
||||
.pool-main { min-width: 0; }.pool-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 78px; padding: 12px 15px; border-bottom: 1px solid var(--line); }
|
||||
.pool-title-row { gap: 8px; }.pool-title-row h3 { font-size: 15px; }.pool-toolbar p { margin-top: 6px; color: #8b97a6; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; }.toolbar-actions { flex-wrap: wrap; justify-content: flex-end; gap: 7px; }.toolbar-actions .el-button + .el-button { margin-left: 0; }
|
||||
.status-tag, .owner-tag, .availability { display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 11px; font-size: 10px; }.status-tag.is-online, .availability.is-ok { color: #16895f; background: #eaf8ef; }.status-tag.is-offline, .availability.is-muted { color: #788696; background: #eef2f5; }.owner-tag { color: #50728c; background: #edf4f8; }.availability.is-error { color: #d94b4b; background: #ffeded; }.availability.is-waiting { color: #b86c1f; background: #fff1dd; }
|
||||
.link-table, .account-table { --el-table-header-bg-color: #f7f9fb; }.link-table :deep(th.el-table__cell), .account-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.member-cell strong, .member-cell small { display: block; }.member-cell small { margin-top: 3px; color: #8d98a7; font-size: 10px; }.muted { color: #9aa4b0; }
|
||||
.configuration-panel { margin-bottom: 16px; padding: 16px; border: 1px solid; border-radius: 10px; }.configuration-panel.is-ready { border-color: #cce8e3; background: #f5fbfa; }.configuration-panel.is-pending { border-color: #f0d8b6; background: #fffaf2; }
|
||||
.configuration-state { display: flex; align-items: center; gap: 12px; }.configuration-state > span { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 9px; color: #fff; background: var(--teal); font-size: 18px; }.is-pending .configuration-state > span { background: #e69a43; }.configuration-state strong { font-size: 14px; }.configuration-state p { margin-top: 4px; color: #718094; font-size: 11px; }
|
||||
.missing-fields { margin-top: 12px; color: #9c651f; font-size: 11px; }.missing-fields code { margin-left: 6px; padding: 3px 6px; border-radius: 4px; background: rgba(224,153,63,.11); }
|
||||
.callback-list { margin: 14px 0 0; border-top: 1px solid rgba(124,150,157,.16); }.callback-list > div { display: grid; grid-template-columns: 120px 1fr 50px; align-items: center; min-height: 40px; border-bottom: 1px solid rgba(124,150,157,.12); font-size: 11px; }.callback-list dt { color: #657488; }.callback-list dd { overflow: hidden; margin: 0; color: #27374c; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }.callback-list button { border: 0; color: #148f83; background: transparent; cursor: pointer; }
|
||||
.install-pool-select { width: 220px; }.install-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.code-card { min-width: 0; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: #fbfcfd; }.code-heading { display: flex; align-items: center; gap: 10px; }.code-heading > span { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 8px; color: #fff; background: var(--teal); font-weight: 700; }.code-heading strong { font-size: 13px; }.code-heading p { margin-top: 3px; color: #7a889a; font-size: 10px; }.code-card pre { min-height: 92px; margin: 14px 0; padding: 13px; overflow: auto; border-radius: 7px; color: #cbe9e6; background: #172a36; white-space: pre-wrap; word-break: break-all; }.code-card pre code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.7; }
|
||||
.rule-panel { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; margin-top: 14px; }.rule-panel > div { display: flex; align-items: flex-start; gap: 9px; min-height: 68px; padding: 12px; border-radius: 9px; color: #69788b; background: #f3f7f8; font-size: 11px; line-height: 1.65; }.rule-panel .el-icon { margin-top: 2px; color: var(--teal); font-size: 17px; }.rule-panel strong { display: block; color: #26364a; }
|
||||
.test-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 14px; padding: 14px 16px; border: 1px dashed #b8d9d4; border-radius: 9px; background: #f7fcfb; }.test-row strong { font-size: 13px; }.test-row p { margin-top: 4px; color: #7c8999; font-size: 10px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.form-tip { display: block; margin-top: 5px; color: #8b97a6; font-size: 10px; }.link-form .el-select, .link-form .el-input-number { width: 100%; }
|
||||
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; }.rule-panel { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.update-time { display: none; }.metric-grid, .install-grid, .form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; } }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -206,10 +206,20 @@ import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import { getDoctors } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { getAvailableSlots, createAppointment, rosterLists, appointmentLists } from '@/api/doctor'
|
||||
import { myPatientCreateAppointment } from '@/api/first_visit'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
apiScene?: 'default' | 'my_patient'
|
||||
}>(),
|
||||
{
|
||||
apiScene: 'default'
|
||||
}
|
||||
)
|
||||
|
||||
interface TimeSlot {
|
||||
time: string
|
||||
available: boolean
|
||||
@@ -703,7 +713,11 @@ const handleConfirm = async () => {
|
||||
|
||||
console.log('提交预约参数:', params)
|
||||
|
||||
await createAppointment(params)
|
||||
if (props.apiScene === 'my_patient') {
|
||||
await myPatientCreateAppointment(params)
|
||||
} else {
|
||||
await createAppointment(params)
|
||||
}
|
||||
|
||||
feedback.msgSuccess('预约成功')
|
||||
visible.value = false
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
|
||||
|
||||
class ConversionController extends BaseAdminController
|
||||
{
|
||||
private const PAGE_PERMISSION = 'firstvisit.conversion/overview';
|
||||
|
||||
public function overview()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看综合数据转化');
|
||||
}
|
||||
|
||||
@set_time_limit(120);
|
||||
|
||||
return $this->data(FirstVisitConversionLogic::overview(
|
||||
$this->request->get(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
));
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\FirstVisitDoctorDashboardLogic;
|
||||
|
||||
class DoctorDashboardController extends BaseAdminController
|
||||
{
|
||||
private const PAGE_PERMISSION = 'firstvisit.doctorDashboard/overview';
|
||||
|
||||
public function overview()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看医生看板');
|
||||
}
|
||||
|
||||
@set_time_limit(120);
|
||||
|
||||
return $this->data(FirstVisitDoctorDashboardLogic::overview(
|
||||
$this->request->get(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
));
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\firstvisit\MyPatientLists;
|
||||
use app\adminapi\lists\firstvisit\MyPatientOrderLists;
|
||||
use app\adminapi\lists\firstvisit\MyPatientProgressLists;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use app\adminapi\validate\doctor\AppointmentValidate;
|
||||
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
|
||||
class MyPatientController extends BaseAdminController
|
||||
{
|
||||
private const LIST_PERMISSION = 'firstvisit.myPatient/lists';
|
||||
|
||||
private string $orderGuardError = '订单不存在或无权操作';
|
||||
|
||||
public function lists()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法访问我的患者');
|
||||
}
|
||||
|
||||
return $this->dataLists(new MyPatientLists());
|
||||
}
|
||||
|
||||
/** 当前角色/部门患者范围内的处方业务订单。 */
|
||||
public function orders()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看患者订单');
|
||||
}
|
||||
|
||||
return $this->dataLists(new MyPatientOrderLists());
|
||||
}
|
||||
|
||||
/** 当前角色/部门患者范围内的挂号面诊进度。 */
|
||||
public function progress()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看面诊进度');
|
||||
}
|
||||
|
||||
return $this->dataLists(new MyPatientProgressLists());
|
||||
}
|
||||
|
||||
/** 当前患者范围内的订单详情;仍要求原订单详情权限。 */
|
||||
public function orderDetail()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->get()->goCheck('detail');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/detail') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$detail = PrescriptionOrderLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($detail === null) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->data($detail);
|
||||
}
|
||||
|
||||
/** 编辑当前患者范围内的订单,参数只允许原编辑表单支持的字段。 */
|
||||
public function orderEdit()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('edit');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/edit') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
if ((float) ($params['amount'] ?? 0) < 0) {
|
||||
return $this->fail('订单金额不能为负数');
|
||||
}
|
||||
|
||||
$params = $this->onlyParams($params, [
|
||||
'id', 'recipient_name', 'recipient_phone', 'shipping_province', 'shipping_city',
|
||||
'shipping_district', 'shipping_address', 'is_follow_up', 'medication_days',
|
||||
'dose_unit', 'dose_count', 'prev_staff', 'service_channel', 'service_package',
|
||||
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra',
|
||||
'remark_assistant', 'pay_order_ids', 'internal_cost',
|
||||
]);
|
||||
$result = PrescriptionOrderLogic::edit($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('保存成功', $result);
|
||||
}
|
||||
|
||||
public function orderAuditPrescription()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPrescription') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::auditPrescription(
|
||||
(int) $params['id'],
|
||||
(string) $params['action'],
|
||||
(string) ($params['remark'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('操作成功', $result);
|
||||
}
|
||||
|
||||
public function orderRevokeRxAudit()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('detail');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPrescription') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::revokeRxAudit((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('处方审核已撤回', $result);
|
||||
}
|
||||
|
||||
public function orderAuditPayment()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPayment');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPayment') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::auditPaymentSlip(
|
||||
(int) $params['id'],
|
||||
(string) $params['action'],
|
||||
(string) ($params['remark'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('操作成功', $result);
|
||||
}
|
||||
|
||||
public function orderRevokePayAudit()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('detail');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPayment') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::revokePayAudit((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('支付单审核已撤回', $result);
|
||||
}
|
||||
|
||||
public function orderDdcode()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('ddcode');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/ddcode') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::ddcode(
|
||||
(int) $params['id'],
|
||||
(string) ($params['express_company'] ?? 'auto'),
|
||||
(string) $params['tracking_number'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('快递单号已保存', $result);
|
||||
}
|
||||
|
||||
public function orderShip()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('ship');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/ship') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::ship(
|
||||
(int) $params['id'],
|
||||
(string) ($params['express_company'] ?? 'auto'),
|
||||
(string) ($params['tracking_number'] ?? ''),
|
||||
(string) ($params['ship_mode'] ?? 'gancao'),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('发货成功', $result);
|
||||
}
|
||||
|
||||
public function orderAddPayOrder()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('addPayOrder');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/addPayOrder') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$params = $this->onlyParams($params, [
|
||||
'id', 'order_type', 'pay_amount', 'pay_remark', 'completion_request', 'pay_create_type',
|
||||
]);
|
||||
$result = PrescriptionOrderLogic::addPayOrder($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('新增支付单成功', $result);
|
||||
}
|
||||
|
||||
public function orderComplete()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('complete');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/complete') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::complete(
|
||||
(int) $params['id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
(int) $params['fulfillment_status']
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('操作成功', $result);
|
||||
}
|
||||
|
||||
public function orderRefund()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('refund');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/refund') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$rawRefundAmount = $params['refund_amount'] ?? null;
|
||||
$refundAmount = ($rawRefundAmount === null || $rawRefundAmount === '')
|
||||
? null
|
||||
: round((float) $rawRefundAmount, 2);
|
||||
$result = PrescriptionOrderLogic::refund(
|
||||
(int) $params['id'],
|
||||
(string) ($params['reason'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$refundAmount
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('退款成功', $result);
|
||||
}
|
||||
|
||||
public function orderWithdraw()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('withdraw');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/withdraw') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::withdraw((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('已撤回', $result);
|
||||
}
|
||||
|
||||
public function orderUploadToPharmacy()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('uploadToPharmacy');
|
||||
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/uploadToPharmacy') === null) {
|
||||
return $this->fail($this->orderGuardError);
|
||||
}
|
||||
|
||||
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('药方上传成功', $result);
|
||||
}
|
||||
|
||||
/** 从“我的患者”页面创建挂号,写操作复用原逻辑但先做患者行级校验。 */
|
||||
public function createAppointment()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法创建挂号');
|
||||
}
|
||||
|
||||
$params = (new AppointmentValidate())->post()->goCheck('create');
|
||||
$diagnosisId = (int) ($params['patient_id'] ?? 0);
|
||||
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $this->adminId, $this->adminInfo)) {
|
||||
return $this->fail('患者不存在或无权操作');
|
||||
}
|
||||
|
||||
$params['assistant_id'] = $this->adminId;
|
||||
$result = AppointmentLogic::create($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(AppointmentLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('挂号成功', $result);
|
||||
}
|
||||
|
||||
/** 从“我的患者”页面取消挂号,按挂号所属诊单再次校验数据范围。 */
|
||||
public function cancelAppointment()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法取消挂号');
|
||||
}
|
||||
|
||||
$params = (new AppointmentValidate())->post()->goCheck('cancel');
|
||||
$appointment = Appointment::findOrEmpty((int) ($params['id'] ?? 0));
|
||||
if ($appointment->isEmpty()) {
|
||||
return $this->fail('挂号记录不存在');
|
||||
}
|
||||
if (!MyPatientLogic::canAccessDiagnosis((int) $appointment->patient_id, $this->adminId, $this->adminInfo)) {
|
||||
return $this->fail('患者不存在或无权操作');
|
||||
}
|
||||
|
||||
$result = AppointmentLogic::cancel($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(AppointmentLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('取消挂号成功');
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::LIST_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
|
||||
private function hasOriginalPermission(string $permission): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($permission, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
|
||||
private function guardOrder(int $orderId, string $permission): ?PrescriptionOrder
|
||||
{
|
||||
$this->orderGuardError = '订单不存在或无权操作';
|
||||
if (!$this->hasPagePermission() || !$this->hasOriginalPermission($permission) || $orderId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$order = PrescriptionOrder::where('id', $orderId)->whereNull('delete_time')->find();
|
||||
if ($order === null) {
|
||||
return null;
|
||||
}
|
||||
if (!MyPatientLogic::canAccessDiagnosis((int) $order->diagnosis_id, $this->adminId, $this->adminInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $params @param array<int,string> $keys */
|
||||
private function onlyParams(array $params, array $keys): array
|
||||
{
|
||||
return array_intersect_key($params, array_flip($keys));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\FirstVisitRegistrationStatsLogic;
|
||||
|
||||
class RegistrationStatsController extends BaseAdminController
|
||||
{
|
||||
private const PAGE_PERMISSION = 'firstvisit.registrationStats/overview';
|
||||
|
||||
public function overview()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法查看挂号统计');
|
||||
}
|
||||
|
||||
@set_time_limit(120);
|
||||
|
||||
return $this->data(FirstVisitRegistrationStatsLogic::overview(
|
||||
$this->request->get(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
));
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\firstvisit;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
|
||||
|
||||
class WecomPromotionController extends BaseAdminController
|
||||
{
|
||||
private const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
|
||||
|
||||
public function overview()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足,无法访问企业微信推广助手');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->data(WecomPromotionLogic::overview(
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$this->request->domain()
|
||||
)));
|
||||
}
|
||||
|
||||
public function authorizationUrl()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->data(WecomPromotionLogic::authorizationUrl(
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$this->request->domain()
|
||||
)));
|
||||
}
|
||||
|
||||
public function verifyAccount()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
|
||||
return $this->run(fn () => $this->success('凭证验证成功', WecomPromotionLogic::verifyAccount(
|
||||
$id,
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function savePool()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('分流方案已保存', WecomPromotionLogic::savePool(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function deletePool()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
|
||||
return $this->run(function () use ($id) {
|
||||
WecomPromotionLogic::deletePool($id, $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->success('分流方案已删除');
|
||||
});
|
||||
}
|
||||
|
||||
public function saveLink()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('推广链接已保存', WecomPromotionLogic::saveLink(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function toggleLink()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
$status = (int) $this->request->post('status', 0);
|
||||
|
||||
return $this->run(function () use ($id, $status) {
|
||||
WecomPromotionLogic::toggleLink($id, $status, $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->success('状态已更新');
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteLink()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
|
||||
return $this->run(function () use ($id) {
|
||||
WecomPromotionLogic::deleteLink($id, $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->success('推广链接已删除');
|
||||
});
|
||||
}
|
||||
|
||||
private function run(callable $callback)
|
||||
{
|
||||
try {
|
||||
return $callback();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function hasPagePermission(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\stats;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\stats\PerformanceDashboardLogic;
|
||||
|
||||
/**
|
||||
* 角色数据驾驶舱。
|
||||
*
|
||||
* 所有数据在服务端按当前管理员的数据范围聚合,前端不参与权限裁剪。
|
||||
*/
|
||||
class PerformanceDashboardController extends BaseAdminController
|
||||
{
|
||||
public function overview()
|
||||
{
|
||||
@set_time_limit(120);
|
||||
|
||||
return $this->data(PerformanceDashboardLogic::overview($this->adminId, $this->adminInfo));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\firstvisit;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
||||
{
|
||||
private const EFFECTIVE_APPOINTMENT_STATUSES = [1, 3, 4];
|
||||
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$query = $this->buildQuery(true, true);
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$today = date('Y-m-d');
|
||||
$upcomingSql = "SELECT MIN(CONCAT(sort_apt.appointment_date, ' ', IFNULL(NULLIF(TRIM(sort_apt.appointment_time), ''), '00:00:00')))"
|
||||
. " FROM {$appointmentTable} sort_apt"
|
||||
. ' WHERE sort_apt.patient_id = d.id'
|
||||
. ' AND sort_apt.status IN (1,4)'
|
||||
. " AND sort_apt.appointment_date >= '{$today}'";
|
||||
|
||||
$rows = $query
|
||||
->field([
|
||||
'd.id', 'd.patient_id', 'd.patient_name', 'd.phone', 'd.gender', 'd.age',
|
||||
'd.diagnosis_date', 'd.diagnosis_type', 'd.syndrome_type', 'd.assistant_id',
|
||||
'd.assign_read_at', 'd.create_time',
|
||||
])
|
||||
->orderRaw("CASE WHEN ({$upcomingSql}) IS NULL THEN 1 ELSE 0 END ASC")
|
||||
->orderRaw("IFNULL(({$upcomingSql}), '9999-12-31 23:59:59') ASC")
|
||||
->order('d.id', 'desc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return $this->appendRelations($rows);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->buildQuery(true, true)->count('d.id');
|
||||
}
|
||||
|
||||
public function extend(): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$tomorrow = date('Y-m-d', strtotime('+1 day'));
|
||||
$dayAfter = date('Y-m-d', strtotime('+2 days'));
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'today' => $this->countByAppointmentDate($today),
|
||||
'tomorrow' => $this->countByAppointmentDate($tomorrow),
|
||||
'day_after' => $this->countByAppointmentDate($dayAfter),
|
||||
],
|
||||
'scope' => MyPatientLogic::scopeMeta($this->adminId, $this->adminInfo),
|
||||
'dates' => [
|
||||
'today' => $today,
|
||||
'tomorrow' => $tomorrow,
|
||||
'day_after' => $dayAfter,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildQuery(bool $applyStatusFilter, bool $applyDateFilter): Query
|
||||
{
|
||||
$diagnosisTable = (new Diagnosis())->getTable();
|
||||
$query = Db::table($diagnosisTable)
|
||||
->alias('d')
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1);
|
||||
|
||||
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
|
||||
$this->applyKeyword($query);
|
||||
|
||||
$statusFilter = $applyStatusFilter ? trim((string) ($this->params['status_filter'] ?? '')) : '';
|
||||
if ($statusFilter === 'unconfirmed') {
|
||||
$viewTable = (new DiagnosisViewRecord())->getTable();
|
||||
$query->whereNotExists(
|
||||
"SELECT 1 FROM {$viewTable} confirm_row"
|
||||
. ' WHERE confirm_row.diagnosis_id = d.id'
|
||||
. ' AND confirm_row.is_confirmed = 1'
|
||||
. ' AND confirm_row.delete_time IS NULL'
|
||||
);
|
||||
}
|
||||
|
||||
$appointmentStatuses = self::EFFECTIVE_APPOINTMENT_STATUSES;
|
||||
if ($statusFilter === 'booked') {
|
||||
$appointmentStatuses = [1];
|
||||
} elseif ($statusFilter === 'completed') {
|
||||
$appointmentStatuses = [3];
|
||||
} elseif ($statusFilter === 'missed') {
|
||||
$appointmentStatuses = [4];
|
||||
}
|
||||
|
||||
$needsAppointmentFilter = in_array($statusFilter, ['booked', 'completed', 'missed'], true);
|
||||
[$startDate, $endDate] = $applyDateFilter ? $this->dateRange() : ['', ''];
|
||||
if ($startDate !== '' || $endDate !== '') {
|
||||
$needsAppointmentFilter = true;
|
||||
}
|
||||
|
||||
if ($needsAppointmentFilter) {
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$conditions = [
|
||||
'filter_apt.patient_id = d.id',
|
||||
'filter_apt.status IN (' . implode(',', $appointmentStatuses) . ')',
|
||||
];
|
||||
if ($startDate !== '') {
|
||||
$conditions[] = "filter_apt.appointment_date >= '{$startDate}'";
|
||||
}
|
||||
if ($endDate !== '') {
|
||||
$conditions[] = "filter_apt.appointment_date <= '{$endDate}'";
|
||||
}
|
||||
$query->whereExists(
|
||||
"SELECT 1 FROM {$appointmentTable} filter_apt WHERE " . implode(' AND ', $conditions)
|
||||
);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyKeyword(Query $query): void
|
||||
{
|
||||
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||
if ($keyword === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$needle = addslashes($keyword);
|
||||
$adminTable = (new Admin())->getTable();
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$query->whereRaw(
|
||||
"(d.patient_name LIKE '%{$needle}%'"
|
||||
. " OR d.phone LIKE '%{$needle}%'"
|
||||
. " OR EXISTS (SELECT 1 FROM {$adminTable} assistant_admin"
|
||||
. ' WHERE assistant_admin.id = CAST(d.assistant_id AS UNSIGNED)'
|
||||
. ' AND assistant_admin.delete_time IS NULL'
|
||||
. " AND assistant_admin.name LIKE '%{$needle}%')"
|
||||
. " OR EXISTS (SELECT 1 FROM {$appointmentTable} keyword_apt"
|
||||
. " INNER JOIN {$adminTable} doctor_admin ON doctor_admin.id = keyword_apt.doctor_id"
|
||||
. ' AND doctor_admin.delete_time IS NULL'
|
||||
. ' WHERE keyword_apt.patient_id = d.id'
|
||||
. ' AND keyword_apt.status IN (1,3,4)'
|
||||
. " AND doctor_admin.name LIKE '%{$needle}%'))"
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function dateRange(): array
|
||||
{
|
||||
$startDate = $this->normalizeDate($this->params['start_date'] ?? '');
|
||||
$endDate = $this->normalizeDate($this->params['end_date'] ?? '');
|
||||
if ($startDate === '' && $endDate !== '') {
|
||||
$startDate = $endDate;
|
||||
}
|
||||
if ($endDate === '' && $startDate !== '') {
|
||||
$endDate = $startDate;
|
||||
}
|
||||
if ($startDate !== '' && $endDate !== '' && $startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
}
|
||||
|
||||
return [$startDate, $endDate];
|
||||
}
|
||||
|
||||
private function normalizeDate($value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
|
||||
}
|
||||
|
||||
private function countByAppointmentDate(string $date): int
|
||||
{
|
||||
$query = $this->buildQuery(false, false);
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$query->whereExists(
|
||||
"SELECT 1 FROM {$appointmentTable} summary_apt"
|
||||
. ' WHERE summary_apt.patient_id = d.id'
|
||||
. " AND summary_apt.appointment_date = '{$date}'"
|
||||
. ' AND summary_apt.status IN (1,3,4)'
|
||||
);
|
||||
|
||||
return (int) $query->count('d.id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function appendRelations(array $rows): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$diagnosisIds = array_values(array_unique(array_map('intval', array_column($rows, 'id'))));
|
||||
$assistantIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'assistant_id')))));
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$appointments = Db::table($appointmentTable)
|
||||
->whereIn('patient_id', $diagnosisIds)
|
||||
->whereIn('status', self::EFFECTIVE_APPOINTMENT_STATUSES)
|
||||
->field(['id', 'patient_id', 'doctor_id', 'appointment_date', 'appointment_time', 'status'])
|
||||
->order('appointment_date', 'asc')
|
||||
->order('appointment_time', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$doctorIds = array_values(array_unique(array_filter(array_map('intval', array_column($appointments, 'doctor_id')))));
|
||||
$adminIds = array_values(array_unique(array_merge($assistantIds, $doctorIds)));
|
||||
$adminNames = $adminIds === [] ? [] : Admin::whereIn('id', $adminIds)->whereNull('delete_time')->column('name', 'id');
|
||||
|
||||
$appointmentMap = [];
|
||||
foreach ($appointments as $appointment) {
|
||||
$diagnosisId = (int) ($appointment['patient_id'] ?? 0);
|
||||
if ($diagnosisId > 0) {
|
||||
$appointmentMap[$diagnosisId][] = $appointment;
|
||||
}
|
||||
}
|
||||
|
||||
$viewTable = (new DiagnosisViewRecord())->getTable();
|
||||
$confirmedIds = Db::table($viewTable)
|
||||
->whereIn('diagnosis_id', $diagnosisIds)
|
||||
->where('is_confirmed', 1)
|
||||
->whereNull('delete_time')
|
||||
->column('diagnosis_id');
|
||||
$confirmedSet = array_fill_keys(array_map('intval', $confirmedIds), true);
|
||||
[$rangeStart, $rangeEnd] = $this->dateRange();
|
||||
$today = date('Y-m-d');
|
||||
$statusFilter = trim((string) ($this->params['status_filter'] ?? ''));
|
||||
$preferredStatuses = [
|
||||
'booked' => [1],
|
||||
'completed' => [3],
|
||||
'missed' => [4],
|
||||
][$statusFilter] ?? [];
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$diagnosisId = (int) $row['id'];
|
||||
$rowAppointments = $appointmentMap[$diagnosisId] ?? [];
|
||||
$primary = $this->pickPrimaryAppointment(
|
||||
$rowAppointments,
|
||||
$rangeStart,
|
||||
$rangeEnd,
|
||||
$today,
|
||||
$preferredStatuses
|
||||
);
|
||||
$completedCount = count(array_filter($rowAppointments, static function (array $appointment): bool {
|
||||
return (int) ($appointment['status'] ?? 0) === 3;
|
||||
}));
|
||||
$assistantId = (int) ($row['assistant_id'] ?? 0);
|
||||
|
||||
$row['diagnosis_id'] = $diagnosisId;
|
||||
$row['source_patient_id'] = (int) ($row['patient_id'] ?? 0);
|
||||
$row['phone_masked'] = $this->maskPhone((string) ($row['phone'] ?? ''));
|
||||
unset($row['phone']);
|
||||
$row['gender_desc'] = (int) ($row['gender'] ?? 0) === 1 ? '男' : '女';
|
||||
$row['diagnosis_date_text'] = $this->formatDiagnosisDate($row['diagnosis_date'] ?? '');
|
||||
$row['assistant_name'] = (string) ($adminNames[$assistantId] ?? '未分配');
|
||||
$row['confirmed'] = isset($confirmedSet[$diagnosisId]) ? 1 : 0;
|
||||
$row['confirmation_text'] = $row['confirmed'] ? '已确认' : '待确认';
|
||||
$row['visit_count'] = $completedCount;
|
||||
$row['revisit_count'] = max(0, $completedCount - 1);
|
||||
$row['appointment_id'] = $primary ? (int) $primary['id'] : 0;
|
||||
$row['appointment_status'] = $primary ? (int) $primary['status'] : 0;
|
||||
$row['appointment_status_text'] = $this->appointmentStatusText((int) ($primary['status'] ?? 0));
|
||||
$row['appointment_doctor_id'] = $primary ? (int) $primary['doctor_id'] : 0;
|
||||
$row['appointment_doctor_name'] = $primary
|
||||
? (string) ($adminNames[(int) $primary['doctor_id']] ?? '未知医生')
|
||||
: '未预约';
|
||||
$row['appointment_time_text'] = $primary ? $this->appointmentTimeText($primary) : '';
|
||||
$row['has_appointment'] = $primary !== null ? 1 : 0;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $appointments
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function pickPrimaryAppointment(
|
||||
array $appointments,
|
||||
string $rangeStart,
|
||||
string $rangeEnd,
|
||||
string $today,
|
||||
array $preferredStatuses = []
|
||||
): ?array
|
||||
{
|
||||
if ($appointments === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidates = $appointments;
|
||||
if ($preferredStatuses !== []) {
|
||||
$candidates = array_values(array_filter($candidates, static function (array $appointment) use ($preferredStatuses): bool {
|
||||
return in_array((int) ($appointment['status'] ?? 0), $preferredStatuses, true);
|
||||
}));
|
||||
}
|
||||
if ($rangeStart !== '' || $rangeEnd !== '') {
|
||||
$candidates = array_values(array_filter($candidates, static function (array $appointment) use ($rangeStart, $rangeEnd): bool {
|
||||
$date = (string) ($appointment['appointment_date'] ?? '');
|
||||
|
||||
return ($rangeStart === '' || $date >= $rangeStart) && ($rangeEnd === '' || $date <= $rangeEnd);
|
||||
}));
|
||||
}
|
||||
if ($candidates === []) {
|
||||
$candidates = $appointments;
|
||||
}
|
||||
|
||||
foreach ($candidates as $appointment) {
|
||||
$status = (int) ($appointment['status'] ?? 0);
|
||||
$date = (string) ($appointment['appointment_date'] ?? '');
|
||||
if (in_array($status, [1, 4], true) && $date >= $today) {
|
||||
return $appointment;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[count($candidates) - 1] ?? null;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
return preg_replace('/^(\d{3})\d{4}(\d{4})$/', '$1****$2', $phone) ?: $phone;
|
||||
}
|
||||
|
||||
private function formatDiagnosisDate($value): string
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value > 0 ? date('Y-m-d', (int) $value) : '';
|
||||
}
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? '' : substr($text, 0, 10);
|
||||
}
|
||||
|
||||
private function appointmentTimeText(array $appointment): string
|
||||
{
|
||||
$time = trim((string) ($appointment['appointment_time'] ?? ''));
|
||||
if (strlen($time) > 5) {
|
||||
$time = substr($time, 0, 5);
|
||||
}
|
||||
|
||||
return trim((string) ($appointment['appointment_date'] ?? '') . ' ' . $time);
|
||||
}
|
||||
|
||||
private function appointmentStatusText(int $status): string
|
||||
{
|
||||
return [1 => '已挂号', 3 => '已完成', 4 => '已过号'][$status] ?? '未挂号';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\firstvisit;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\Order;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* “我的患者”内嵌订单列表。
|
||||
*
|
||||
* 订单可见性始终锚定 diagnosis 别名 d,并复用 MyPatientLogic;订单创建人仅用于展示,
|
||||
* 不能作为患者归属或数据范围条件。
|
||||
*/
|
||||
class MyPatientOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
||||
{
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->buildQuery()
|
||||
->field([
|
||||
'po.id', 'po.order_no', 'po.prescription_id', 'po.diagnosis_id', 'po.creator_id',
|
||||
'po.recipient_name', 'po.recipient_phone', 'po.fee_type', 'po.amount',
|
||||
'po.prescription_audit_status', 'po.payment_slip_audit_status',
|
||||
'po.fulfillment_status', 'po.express_company', 'po.tracking_number', 'po.ship_mode',
|
||||
'po.gancao_reciperl_order_no', 'po.ej_pharmacy_order_no',
|
||||
'po.gancao_submit_time', 'po.ej_pharmacy_submit_time',
|
||||
'po.ej_pharmacy_status', 'po.ej_pharmacy_review_status', 'po.refund_amount',
|
||||
'po.create_time',
|
||||
'd.patient_name', 'd.phone AS patient_phone', 'd.assistant_id',
|
||||
])
|
||||
->order('po.id', 'desc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return $this->appendRelations($rows);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->buildQuery()->count('po.id');
|
||||
}
|
||||
|
||||
public function extend(): array
|
||||
{
|
||||
$query = $this->buildQuery();
|
||||
$pendingQuery = clone $query;
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'orders' => (int) (clone $query)->count('po.id'),
|
||||
'amount' => round((float) (clone $query)->sum('po.amount'), 2),
|
||||
'pending' => (int) $pendingQuery
|
||||
->where(function ($q) {
|
||||
$q->where('po.prescription_audit_status', 0)
|
||||
->whereOr('po.payment_slip_audit_status', 0);
|
||||
})
|
||||
->count('po.id'),
|
||||
'completed' => (int) (clone $query)->whereIn('po.fulfillment_status', [3, 6])->count('po.id'),
|
||||
],
|
||||
'scope' => MyPatientLogic::scopeMeta($this->adminId, $this->adminInfo),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildQuery(): Query
|
||||
{
|
||||
$query = PrescriptionOrder::alias('po')
|
||||
->join('tcm_diagnosis d', 'po.diagnosis_id = d.id')
|
||||
->whereNull('po.delete_time')
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1);
|
||||
|
||||
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
|
||||
$this->applyKeyword($query);
|
||||
$this->applyStatusFilters($query);
|
||||
$this->applyDateFilter($query);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyKeyword(Query $query): void
|
||||
{
|
||||
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||
if ($keyword === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$like = '%' . $keyword . '%';
|
||||
$q->whereLike('po.order_no', $like)
|
||||
->whereOr('d.patient_name', 'like', $like)
|
||||
->whereOr('d.phone', 'like', $like)
|
||||
->whereOr('po.recipient_name', 'like', $like)
|
||||
->whereOr('po.recipient_phone', 'like', $like);
|
||||
if (preg_match('/^\d+$/', $keyword)) {
|
||||
$id = (int) $keyword;
|
||||
if ($id > 0) {
|
||||
$q->whereOr('po.id', $id)
|
||||
->whereOr('po.prescription_id', $id)
|
||||
->whereOr('po.diagnosis_id', $id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function applyStatusFilters(Query $query): void
|
||||
{
|
||||
foreach (['prescription_audit_status', 'payment_slip_audit_status', 'fulfillment_status'] as $field) {
|
||||
$raw = $this->params[$field] ?? '';
|
||||
if ($raw === '' || $raw === null) {
|
||||
continue;
|
||||
}
|
||||
$query->where('po.' . $field, (int) $raw);
|
||||
}
|
||||
}
|
||||
|
||||
private function applyDateFilter(Query $query): void
|
||||
{
|
||||
[$startDate, $endDate] = $this->dateRange();
|
||||
if ($startDate !== '') {
|
||||
$query->where('po.create_time', '>=', strtotime($startDate . ' 00:00:00'));
|
||||
}
|
||||
if ($endDate !== '') {
|
||||
$query->where('po.create_time', '<=', strtotime($endDate . ' 23:59:59'));
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function dateRange(): array
|
||||
{
|
||||
$startDate = $this->normalizeDate($this->params['start_date'] ?? '');
|
||||
$endDate = $this->normalizeDate($this->params['end_date'] ?? '');
|
||||
if ($startDate === '' && $endDate !== '') {
|
||||
$startDate = $endDate;
|
||||
}
|
||||
if ($endDate === '' && $startDate !== '') {
|
||||
$endDate = $startDate;
|
||||
}
|
||||
if ($startDate !== '' && $endDate !== '' && $startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
}
|
||||
|
||||
return [$startDate, $endDate];
|
||||
}
|
||||
|
||||
private function normalizeDate($value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function appendRelations(array $rows): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$orderIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'id')))));
|
||||
$prescriptionIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'prescription_id')))));
|
||||
$creatorIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'creator_id')))));
|
||||
$assistantIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'assistant_id')))));
|
||||
|
||||
$prescriptionMap = [];
|
||||
$doctorIds = [];
|
||||
if ($prescriptionIds !== []) {
|
||||
$prescriptions = Prescription::whereIn('id', $prescriptionIds)
|
||||
->whereNull('delete_time')
|
||||
->field(['id', 'creator_id', 'doctor_name'])
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($prescriptions as $prescription) {
|
||||
$prescriptionId = (int) ($prescription['id'] ?? 0);
|
||||
$doctorId = (int) ($prescription['creator_id'] ?? 0);
|
||||
if ($prescriptionId > 0) {
|
||||
$prescriptionMap[$prescriptionId] = $prescription;
|
||||
}
|
||||
if ($doctorId > 0) {
|
||||
$doctorIds[] = $doctorId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$adminIds = array_values(array_unique(array_merge($creatorIds, $assistantIds, $doctorIds)));
|
||||
$adminNames = $adminIds === []
|
||||
? []
|
||||
: Admin::whereIn('id', $adminIds)->whereNull('delete_time')->column('name', 'id');
|
||||
|
||||
$linkCounts = [];
|
||||
$paidTotals = [];
|
||||
if ($orderIds !== []) {
|
||||
$linkRows = PrescriptionOrderPayOrder::whereIn('prescription_order_id', $orderIds)
|
||||
->field(['prescription_order_id', 'pay_order_id'])
|
||||
->select()
|
||||
->toArray();
|
||||
$payOrderIds = array_values(array_unique(array_filter(array_map('intval', array_column($linkRows, 'pay_order_id')))));
|
||||
$payOrders = $payOrderIds === []
|
||||
? []
|
||||
: Order::whereIn('id', $payOrderIds)
|
||||
->whereNull('delete_time')
|
||||
->field(['id', 'amount', 'status'])
|
||||
->select()
|
||||
->toArray();
|
||||
$payOrderMap = [];
|
||||
foreach ($payOrders as $payOrder) {
|
||||
$payOrderMap[(int) ($payOrder['id'] ?? 0)] = $payOrder;
|
||||
}
|
||||
foreach ($linkRows as $linkRow) {
|
||||
$orderId = (int) ($linkRow['prescription_order_id'] ?? 0);
|
||||
if ($orderId > 0) {
|
||||
$linkCounts[$orderId] = ($linkCounts[$orderId] ?? 0) + 1;
|
||||
}
|
||||
$payOrder = $payOrderMap[(int) ($linkRow['pay_order_id'] ?? 0)] ?? [];
|
||||
if ($orderId > 0 && in_array((int) ($payOrder['status'] ?? 0), [2, 5], true)) {
|
||||
$paidTotals[$orderId] = round(
|
||||
(float) ($paidTotals[$orderId] ?? 0) + (float) ($payOrder['amount'] ?? 0),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$assistantByDiagnosis = [];
|
||||
foreach ($rows as $row) {
|
||||
$diagnosisId = (int) ($row['diagnosis_id'] ?? 0);
|
||||
if ($diagnosisId > 0) {
|
||||
$assistantByDiagnosis[$diagnosisId] = (int) ($row['assistant_id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$claimByOrder = [];
|
||||
if ($orderIds !== []) {
|
||||
$claimRows = Db::name('pharmacy_submission_claim')
|
||||
->whereIn('prescription_order_id', $orderIds)
|
||||
->field(['prescription_order_id', 'target', 'status', 'lease_expires_at'])
|
||||
->order('source_revision', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($claimRows as $claimRow) {
|
||||
$orderId = (int) ($claimRow['prescription_order_id'] ?? 0);
|
||||
if ($orderId > 0 && !isset($claimByOrder[$orderId])) {
|
||||
$claimByOrder[$orderId] = $claimRow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$prescription = $prescriptionMap[(int) ($row['prescription_id'] ?? 0)] ?? [];
|
||||
$doctorId = (int) ($prescription['creator_id'] ?? 0);
|
||||
$doctorName = trim((string) ($prescription['doctor_name'] ?? ''));
|
||||
$creatorId = (int) ($row['creator_id'] ?? 0);
|
||||
$assistantId = (int) ($row['assistant_id'] ?? 0);
|
||||
|
||||
$row['patient_phone_masked'] = $this->maskPhone((string) ($row['patient_phone'] ?? ''));
|
||||
$row['recipient_phone_masked'] = $this->maskPhone((string) ($row['recipient_phone'] ?? ''));
|
||||
unset($row['patient_phone'], $row['recipient_phone']);
|
||||
$row['creator_name'] = (string) ($adminNames[$creatorId] ?? '—');
|
||||
$row['assistant_name'] = (string) ($adminNames[$assistantId] ?? '未分配');
|
||||
$row['doctor_name'] = $doctorName !== '' ? $doctorName : (string) ($adminNames[$doctorId] ?? '—');
|
||||
$row['linked_pay_order_count'] = (int) ($linkCounts[(int) $row['id']] ?? 0);
|
||||
$row['linked_pay_paid_total'] = (float) ($paidTotals[(int) $row['id']] ?? 0);
|
||||
$claim = $claimByOrder[(int) $row['id']] ?? [];
|
||||
$row['pharmacy_claim_target'] = (string) ($claim['target'] ?? '');
|
||||
$row['pharmacy_claim_status'] = (string) ($claim['status'] ?? '');
|
||||
$row['pharmacy_claim_lease_expires_at'] = (int) ($claim['lease_expires_at'] ?? 0);
|
||||
$row['can_upload_pharmacy'] = PrescriptionOrderLogic::canUploadToPharmacy(
|
||||
$row,
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$assistantByDiagnosis
|
||||
);
|
||||
$row['create_time_text'] = $this->formatTimestamp($row['create_time'] ?? 0);
|
||||
$row['fee_type_text'] = $this->feeTypeText((int) ($row['fee_type'] ?? 0));
|
||||
$row['prescription_audit_text'] = $this->auditStatusText((int) ($row['prescription_audit_status'] ?? 0));
|
||||
$row['payment_slip_audit_text'] = $this->auditStatusText((int) ($row['payment_slip_audit_status'] ?? 0));
|
||||
$row['fulfillment_text'] = $this->fulfillmentStatusText((int) ($row['fulfillment_status'] ?? 0));
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
return preg_replace('/^(\d{3})\d{4}(\d{4})$/', '$1****$2', $phone) ?: $phone;
|
||||
}
|
||||
|
||||
private function formatTimestamp($value): string
|
||||
{
|
||||
return is_numeric($value) && (int) $value > 0 ? date('Y-m-d H:i', (int) $value) : '';
|
||||
}
|
||||
|
||||
private function auditStatusText(int $status): string
|
||||
{
|
||||
return [0 => '待审核', 1 => '已通过', 2 => '已驳回'][$status] ?? '未知';
|
||||
}
|
||||
|
||||
private function feeTypeText(int $type): string
|
||||
{
|
||||
return [1 => '挂号', 2 => '问诊', 3 => '药品', 4 => '首付', 5 => '尾款', 6 => '其他', 7 => '全部'][$type] ?? '其他';
|
||||
}
|
||||
|
||||
private function fulfillmentStatusText(int $status): string
|
||||
{
|
||||
return [
|
||||
1 => '待双审通过', 2 => '待发货', 3 => '已完成', 4 => '已取消',
|
||||
5 => '已发货', 6 => '已签收', 7 => '进行中', 8 => '暂不制药',
|
||||
9 => '拒收', 10 => '退款', 11 => '保留药方', 12 => '制药缓发',
|
||||
][$status] ?? '未知';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\firstvisit;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\doctor\Roster;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\service\doctor\RosterSegmentService;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* “我的患者”内嵌面诊进度。
|
||||
*
|
||||
* 一条挂号一行;只返回脱敏患者信息,并严格复用 MyPatientLogic 的患者级范围。
|
||||
*/
|
||||
class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
||||
{
|
||||
private const EFFECTIVE_STATUSES = [1, 3, 4];
|
||||
private const AVG_MINUTES_PER_VISIT = 15;
|
||||
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->buildQuery(true)
|
||||
->field([
|
||||
'a.id', 'a.patient_id AS diagnosis_id', 'a.doctor_id', 'a.appointment_date',
|
||||
'a.appointment_time', 'a.appointment_type', 'a.status', 'a.create_time',
|
||||
'd.patient_id AS source_patient_id', 'd.patient_name', 'd.phone', 'd.gender', 'd.age',
|
||||
'd.assistant_id', 'doctor_admin.name AS doctor_name', 'assistant_admin.name AS assistant_name',
|
||||
])
|
||||
->order('a.appointment_date', 'asc')
|
||||
->order('a.appointment_time', 'asc')
|
||||
->order('a.id', 'asc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return $this->appendProgress($rows);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->buildQuery(true)->count('a.id');
|
||||
}
|
||||
|
||||
public function extend(): array
|
||||
{
|
||||
$query = $this->buildQuery(false);
|
||||
[$startDate, $endDate] = $this->dateRange();
|
||||
$summary = [
|
||||
'total' => (int) (clone $query)->count('a.id'),
|
||||
'booked' => (int) (clone $query)->where('a.status', 1)->count('a.id'),
|
||||
'completed' => (int) (clone $query)->where('a.status', 3)->count('a.id'),
|
||||
'missed' => (int) (clone $query)->where('a.status', 4)->count('a.id'),
|
||||
];
|
||||
$scheduleMode = $this->usesOwnershipSchedule() ? 'ownership' : 'roster';
|
||||
$weekSchedule = $scheduleMode === 'ownership' ? $this->ownershipWeekSchedule() : $this->weekSchedule();
|
||||
$todaySchedule = $weekSchedule[0] ?? $this->emptyScheduleDay(date('Y-m-d'));
|
||||
$todayOverview = $scheduleMode === 'ownership'
|
||||
? [
|
||||
'total_visits' => (int) ($todaySchedule['total_appointments'] ?? 0),
|
||||
'booked' => (int) ($todaySchedule['waiting_appointments'] ?? 0),
|
||||
'completed' => (int) ($todaySchedule['completed_appointments'] ?? 0),
|
||||
'missed' => (int) ($todaySchedule['missed_appointments'] ?? 0),
|
||||
'empty_slots' => 0,
|
||||
'doctor_count' => (int) ($todaySchedule['doctor_count'] ?? 0),
|
||||
]
|
||||
: [
|
||||
'total_visits' => (int) ($todaySchedule['total_slots'] ?? 0),
|
||||
'booked' => (int) ($todaySchedule['booked_slots'] ?? 0),
|
||||
'completed' => 0,
|
||||
'missed' => 0,
|
||||
'empty_slots' => (int) ($todaySchedule['empty_slots'] ?? 0),
|
||||
'doctor_count' => (int) ($todaySchedule['doctor_count'] ?? 0),
|
||||
];
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'schedule_mode' => $scheduleMode,
|
||||
'today_overview' => $todayOverview,
|
||||
'week_schedule' => $weekSchedule,
|
||||
'scope' => MyPatientLogic::scopeMeta($this->adminId, $this->adminInfo),
|
||||
'dates' => ['start' => $startDate, 'end' => $endDate],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildQuery(bool $applyStatus): Query
|
||||
{
|
||||
$query = Appointment::alias('a')
|
||||
->join('tcm_diagnosis d', 'a.patient_id = d.id')
|
||||
->leftJoin('admin doctor_admin', 'a.doctor_id = doctor_admin.id')
|
||||
->leftJoin('admin assistant_admin', 'CAST(d.assistant_id AS UNSIGNED) = assistant_admin.id')
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1);
|
||||
|
||||
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
|
||||
$this->applyKeyword($query);
|
||||
$this->applyDateFilter($query);
|
||||
|
||||
if ($applyStatus) {
|
||||
$status = $this->params['status'] ?? '';
|
||||
if ($status !== '' && $status !== null && in_array((int) $status, self::EFFECTIVE_STATUSES, true)) {
|
||||
$query->where('a.status', (int) $status);
|
||||
} else {
|
||||
$query->whereIn('a.status', self::EFFECTIVE_STATUSES);
|
||||
}
|
||||
} else {
|
||||
$query->whereIn('a.status', self::EFFECTIVE_STATUSES);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyKeyword(Query $query): void
|
||||
{
|
||||
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||
if ($keyword === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$like = '%' . $keyword . '%';
|
||||
$q->whereLike('d.patient_name', $like)
|
||||
->whereOr('d.phone', 'like', $like)
|
||||
->whereOr('doctor_admin.name', 'like', $like)
|
||||
->whereOr('assistant_admin.name', 'like', $like);
|
||||
if (preg_match('/^\d+$/', $keyword)) {
|
||||
$id = (int) $keyword;
|
||||
if ($id > 0) {
|
||||
$q->whereOr('a.id', $id)->whereOr('d.id', $id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function applyDateFilter(Query $query): void
|
||||
{
|
||||
[$startDate, $endDate] = $this->dateRange();
|
||||
$query->whereBetween('a.appointment_date', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function dateRange(): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$startDate = $this->normalizeDate($this->params['start_date'] ?? '') ?: $today;
|
||||
$endDate = $this->normalizeDate($this->params['end_date'] ?? '') ?: $startDate;
|
||||
if ($startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
}
|
||||
|
||||
$startTs = strtotime($startDate);
|
||||
$endTs = strtotime($endDate);
|
||||
if ($startTs !== false && $endTs !== false && $endTs - $startTs > 31 * 86400) {
|
||||
$endDate = date('Y-m-d', $startTs + 31 * 86400);
|
||||
}
|
||||
|
||||
return [$startDate, $endDate];
|
||||
}
|
||||
|
||||
private function normalizeDate($value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function appendProgress(array $rows): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$diagnosisIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'diagnosis_id')))));
|
||||
$appointmentIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'id')))));
|
||||
$queuePositionMap = $this->queuePositionMap($rows);
|
||||
|
||||
$confirmedSet = [];
|
||||
if ($diagnosisIds !== []) {
|
||||
$viewTable = (new DiagnosisViewRecord())->getTable();
|
||||
$confirmedIds = Db::table($viewTable)
|
||||
->whereIn('diagnosis_id', $diagnosisIds)
|
||||
->where('is_confirmed', 1)
|
||||
->whereNull('delete_time')
|
||||
->column('diagnosis_id');
|
||||
$confirmedSet = array_fill_keys(array_map('intval', $confirmedIds), true);
|
||||
}
|
||||
|
||||
$prescriptionMap = [];
|
||||
if ($appointmentIds !== []) {
|
||||
$prescriptions = Prescription::whereIn('appointment_id', $appointmentIds)
|
||||
->whereNull('delete_time')
|
||||
->where('void_status', 0)
|
||||
->field(['id', 'appointment_id', 'audit_status', 'is_system_auto'])
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($prescriptions as $prescription) {
|
||||
$appointmentId = (int) ($prescription['appointment_id'] ?? 0);
|
||||
if ($appointmentId > 0 && !isset($prescriptionMap[$appointmentId])) {
|
||||
$prescriptionMap[$appointmentId] = $prescription;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$appointmentId = (int) ($row['id'] ?? 0);
|
||||
$diagnosisId = (int) ($row['diagnosis_id'] ?? 0);
|
||||
$status = (int) ($row['status'] ?? 0);
|
||||
$prescription = $prescriptionMap[$appointmentId] ?? [];
|
||||
$confirmed = isset($confirmedSet[$diagnosisId]);
|
||||
$prescribed = $prescription !== [];
|
||||
$aheadCount = $status === 1 ? (int) ($queuePositionMap[$appointmentId] ?? 0) : 0;
|
||||
|
||||
$row['phone_masked'] = $this->maskPhone((string) ($row['phone'] ?? ''));
|
||||
unset($row['phone']);
|
||||
$row['gender_desc'] = (int) ($row['gender'] ?? 0) === 1 ? '男' : '女';
|
||||
$row['assistant_name'] = trim((string) ($row['assistant_name'] ?? '')) ?: '未分配';
|
||||
$row['doctor_name'] = trim((string) ($row['doctor_name'] ?? '')) ?: '未知医生';
|
||||
$row['appointment_time_text'] = $this->appointmentTimeText($row);
|
||||
$row['status_text'] = $this->appointmentStatusText($status);
|
||||
$row['appointment_type_text'] = $this->appointmentTypeText((string) ($row['appointment_type'] ?? ''));
|
||||
$row['registered'] = 1;
|
||||
$row['diagnosis_confirmed'] = $confirmed ? 1 : 0;
|
||||
$row['visit_completed'] = $status === 3 ? 1 : 0;
|
||||
$row['has_prescription'] = $prescribed ? 1 : 0;
|
||||
$row['prescription_id'] = (int) ($prescription['id'] ?? 0);
|
||||
$row['prescription_audit_status'] = $prescribed ? (int) ($prescription['audit_status'] ?? 0) : -1;
|
||||
$row['progress_text'] = $this->progressText($confirmed, $status === 3, $prescribed, $status);
|
||||
$row['queue_no'] = $status === 1 ? $aheadCount + 1 : 0;
|
||||
$row['ahead_count'] = $aheadCount;
|
||||
$row['estimated_wait_minutes'] = $aheadCount * self::AVG_MINUTES_PER_VISIT;
|
||||
$row['queue_status'] = $this->queueStatus($status, $confirmed, $aheadCount);
|
||||
$row['queue_status_text'] = $this->queueStatusText((string) $row['queue_status']);
|
||||
$row['is_self_patient'] = (
|
||||
(int) ($row['assistant_id'] ?? 0) === $this->adminId
|
||||
|| (int) ($row['doctor_id'] ?? 0) === $this->adminId
|
||||
) ? 1 : 0;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候诊位次按 progress.vue 的真实规则计算:同医生、同日、待就诊,按预约时刻和挂号 ID 升序。
|
||||
* 队列计算读取完整医生队列,只向当前范围列表返回人数,不暴露范围外患者身份。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @return array<int,int>
|
||||
*/
|
||||
private function queuePositionMap(array $rows): array
|
||||
{
|
||||
$doctorIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'doctor_id')))));
|
||||
$dates = array_values(array_unique(array_filter(array_map('strval', array_column($rows, 'appointment_date')))));
|
||||
if ($doctorIds === [] || $dates === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$queueRows = Appointment::whereIn('doctor_id', $doctorIds)
|
||||
->whereIn('appointment_date', $dates)
|
||||
->where('status', 1)
|
||||
->field(['id', 'doctor_id', 'appointment_date', 'appointment_time'])
|
||||
->order('doctor_id', 'asc')
|
||||
->order('appointment_date', 'asc')
|
||||
->order('appointment_time', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$groupCounts = [];
|
||||
$positions = [];
|
||||
foreach ($queueRows as $queueRow) {
|
||||
$group = (int) ($queueRow['doctor_id'] ?? 0) . '|' . (string) ($queueRow['appointment_date'] ?? '');
|
||||
$positions[(int) ($queueRow['id'] ?? 0)] = (int) ($groupCounts[$group] ?? 0);
|
||||
$groupCounts[$group] = (int) ($groupCounts[$group] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未来七天号源:完全复用 paiban/availableSlots 的生成口径,按医生+日期+时刻去重。
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function weekSchedule(): array
|
||||
{
|
||||
$startDate = date('Y-m-d');
|
||||
$endDate = date('Y-m-d', strtotime($startDate . ' +6 days'));
|
||||
$days = [];
|
||||
for ($offset = 0; $offset < 7; $offset++) {
|
||||
$date = date('Y-m-d', strtotime($startDate . " +{$offset} days"));
|
||||
$days[$date] = $this->emptyScheduleDay($date);
|
||||
}
|
||||
|
||||
$doctorIds = $this->visibleDoctorIds($startDate, $endDate);
|
||||
if ($doctorIds === []) {
|
||||
return array_values($days);
|
||||
}
|
||||
|
||||
$rosters = Roster::whereIn('doctor_id', $doctorIds)
|
||||
->whereBetween('date', [$startDate, $endDate])
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->field(['doctor_id', 'date', 'period', 'start_time', 'end_time', 'slot_minutes', 'quota'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$doctorNames = Admin::whereIn('id', $doctorIds)->column('name', 'id');
|
||||
|
||||
$doctorSlotSets = [];
|
||||
$doctorWindowSets = [];
|
||||
foreach ($rosters as $roster) {
|
||||
$date = (string) ($roster['date'] ?? '');
|
||||
$doctorId = (int) ($roster['doctor_id'] ?? 0);
|
||||
$window = RosterSegmentService::resolveWindow($roster);
|
||||
if (!isset($days[$date]) || $doctorId <= 0 || $window === null) {
|
||||
continue;
|
||||
}
|
||||
[$startTime, $endTime] = $window;
|
||||
$times = RosterSegmentService::generateSlotTimes(
|
||||
$startTime,
|
||||
$endTime,
|
||||
RosterSegmentService::normalizeSlotMinutes($roster['slot_minutes'] ?? 15)
|
||||
);
|
||||
$times = RosterSegmentService::applyQuotaCap($times, (int) ($roster['quota'] ?? 0));
|
||||
foreach ($times as $time) {
|
||||
$doctorSlotSets[$date][$doctorId][$time] = true;
|
||||
}
|
||||
$doctorWindowSets[$date][$doctorId][$startTime . '-' . $endTime] = true;
|
||||
}
|
||||
|
||||
$appointments = Appointment::whereIn('doctor_id', $doctorIds)
|
||||
->whereBetween('appointment_date', [$startDate, $endDate])
|
||||
->where('status', 1)
|
||||
->field(['doctor_id', 'appointment_date', 'appointment_time'])
|
||||
->select()
|
||||
->toArray();
|
||||
$doctorBookedSets = [];
|
||||
foreach ($appointments as $appointment) {
|
||||
$date = (string) ($appointment['appointment_date'] ?? '');
|
||||
$doctorId = (int) ($appointment['doctor_id'] ?? 0);
|
||||
$time = substr((string) ($appointment['appointment_time'] ?? ''), 0, 5);
|
||||
if (isset($doctorSlotSets[$date][$doctorId][$time])) {
|
||||
$doctorBookedSets[$date][$doctorId][$time] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($days as $date => &$day) {
|
||||
$doctorDetails = [];
|
||||
$total = 0;
|
||||
$booked = 0;
|
||||
foreach ($doctorSlotSets[$date] ?? [] as $doctorId => $slotSet) {
|
||||
$doctorTotal = count($slotSet);
|
||||
$doctorBooked = count($doctorBookedSets[$date][$doctorId] ?? []);
|
||||
$total += $doctorTotal;
|
||||
$booked += $doctorBooked;
|
||||
$scheduleWindows = array_values(array_keys($doctorWindowSets[$date][$doctorId] ?? []));
|
||||
sort($scheduleWindows, SORT_STRING);
|
||||
$doctorDetails[] = [
|
||||
'doctor_id' => (int) $doctorId,
|
||||
'doctor_name' => trim((string) ($doctorNames[$doctorId] ?? '')) ?: '未知医生',
|
||||
'schedule_windows' => $scheduleWindows,
|
||||
'total_slots' => $doctorTotal,
|
||||
'booked_slots' => $doctorBooked,
|
||||
'empty_slots' => max(0, $doctorTotal - $doctorBooked),
|
||||
];
|
||||
}
|
||||
usort($doctorDetails, static function (array $left, array $right): int {
|
||||
return $right['booked_slots'] <=> $left['booked_slots']
|
||||
?: $right['total_slots'] <=> $left['total_slots']
|
||||
?: strcmp((string) $left['doctor_name'], (string) $right['doctor_name']);
|
||||
});
|
||||
$day['total_slots'] = $total;
|
||||
$day['booked_slots'] = $booked;
|
||||
$day['empty_slots'] = max(0, $total - $booked);
|
||||
$day['doctor_count'] = count($doctorDetails);
|
||||
$day['doctors'] = $doctorDetails;
|
||||
}
|
||||
unset($day);
|
||||
|
||||
return array_values($days);
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助“本人归属”只统计其患者的真实挂号,不再把历史接诊医生的整周号源算到本人名下。
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function ownershipWeekSchedule(): array
|
||||
{
|
||||
$startDate = date('Y-m-d');
|
||||
$endDate = date('Y-m-d', strtotime($startDate . ' +6 days'));
|
||||
$days = [];
|
||||
for ($offset = 0; $offset < 7; $offset++) {
|
||||
$date = date('Y-m-d', strtotime($startDate . " +{$offset} days"));
|
||||
$days[$date] = $this->emptyScheduleDay($date);
|
||||
}
|
||||
|
||||
$query = Appointment::alias('ownership_a')
|
||||
->join('tcm_diagnosis d', 'ownership_a.patient_id = d.id')
|
||||
->leftJoin('admin ownership_doctor', 'ownership_a.doctor_id = ownership_doctor.id')
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1)
|
||||
->whereBetween('ownership_a.appointment_date', [$startDate, $endDate])
|
||||
->whereIn('ownership_a.status', self::EFFECTIVE_STATUSES);
|
||||
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
|
||||
|
||||
$appointments = $query
|
||||
->field([
|
||||
'ownership_a.id', 'ownership_a.doctor_id', 'ownership_a.appointment_date',
|
||||
'ownership_a.appointment_time', 'ownership_a.status',
|
||||
'ownership_doctor.name AS doctor_name',
|
||||
])
|
||||
->order('ownership_a.appointment_date', 'asc')
|
||||
->order('ownership_a.appointment_time', 'asc')
|
||||
->order('ownership_a.id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$doctorDetails = [];
|
||||
foreach ($appointments as $appointment) {
|
||||
$date = (string) ($appointment['appointment_date'] ?? '');
|
||||
$doctorId = (int) ($appointment['doctor_id'] ?? 0);
|
||||
if (!isset($days[$date]) || $doctorId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($doctorDetails[$date][$doctorId])) {
|
||||
$doctorDetails[$date][$doctorId] = [
|
||||
'doctor_id' => $doctorId,
|
||||
'doctor_name' => trim((string) ($appointment['doctor_name'] ?? '')) ?: '未知医生',
|
||||
'appointment_time_set' => [],
|
||||
'total_appointments' => 0,
|
||||
'waiting_appointments' => 0,
|
||||
'completed_appointments' => 0,
|
||||
'missed_appointments' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$time = substr((string) ($appointment['appointment_time'] ?? ''), 0, 5);
|
||||
if ($time !== '') {
|
||||
$doctorDetails[$date][$doctorId]['appointment_time_set'][$time] = true;
|
||||
}
|
||||
$status = (int) ($appointment['status'] ?? 0);
|
||||
$doctorDetails[$date][$doctorId]['total_appointments']++;
|
||||
if ($status === 1) {
|
||||
$doctorDetails[$date][$doctorId]['waiting_appointments']++;
|
||||
} elseif ($status === 3) {
|
||||
$doctorDetails[$date][$doctorId]['completed_appointments']++;
|
||||
} elseif ($status === 4) {
|
||||
$doctorDetails[$date][$doctorId]['missed_appointments']++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($days as $date => &$day) {
|
||||
$rows = [];
|
||||
foreach ($doctorDetails[$date] ?? [] as $doctor) {
|
||||
$times = array_values(array_keys($doctor['appointment_time_set'] ?? []));
|
||||
sort($times, SORT_STRING);
|
||||
unset($doctor['appointment_time_set']);
|
||||
$doctor['appointment_times'] = $times;
|
||||
$rows[] = $doctor;
|
||||
}
|
||||
usort($rows, static function (array $left, array $right): int {
|
||||
return $right['waiting_appointments'] <=> $left['waiting_appointments']
|
||||
?: $right['total_appointments'] <=> $left['total_appointments']
|
||||
?: strcmp((string) $left['doctor_name'], (string) $right['doctor_name']);
|
||||
});
|
||||
|
||||
$day['total_appointments'] = array_sum(array_column($rows, 'total_appointments'));
|
||||
$day['waiting_appointments'] = array_sum(array_column($rows, 'waiting_appointments'));
|
||||
$day['completed_appointments'] = array_sum(array_column($rows, 'completed_appointments'));
|
||||
$day['missed_appointments'] = array_sum(array_column($rows, 'missed_appointments'));
|
||||
$day['doctor_count'] = count($rows);
|
||||
$day['doctors'] = $rows;
|
||||
}
|
||||
unset($day);
|
||||
|
||||
return array_values($days);
|
||||
}
|
||||
|
||||
private function usesOwnershipSchedule(): bool
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roleIds = $this->currentRoleIds();
|
||||
|
||||
return in_array(2, $roleIds, true) && array_intersect($roleIds, [3, 7, 8]) === [];
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private function visibleDoctorIds(string $startDate, string $endDate): array
|
||||
{
|
||||
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
|
||||
$doctorIds = array_values(array_unique(array_map('intval', Roster::whereBetween('date', [$startDate, $endDate])
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->column('doctor_id'))));
|
||||
|
||||
return $this->activeDoctorIds($doctorIds);
|
||||
}
|
||||
|
||||
$roleIds = $this->currentRoleIds();
|
||||
$isTeamRole = array_intersect($roleIds, [3, 7, 8]) !== [];
|
||||
$isDoctor = in_array(1, $roleIds, true);
|
||||
$isAssistant = in_array(2, $roleIds, true);
|
||||
|
||||
// 纯医生账号的概览只统计本人排班,避免同一患者曾由其他医生接诊时放大到其他医生。
|
||||
if (!$isTeamRole && $isDoctor && !$isAssistant) {
|
||||
return $this->activeDoctorIds([$this->adminId]);
|
||||
}
|
||||
|
||||
$query = Appointment::alias('scope_a')
|
||||
->join('tcm_diagnosis d', 'scope_a.patient_id = d.id')
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1)
|
||||
->whereIn('scope_a.status', self::EFFECTIVE_STATUSES)
|
||||
->where('scope_a.doctor_id', '>', 0);
|
||||
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
|
||||
|
||||
$doctorIds = array_values(array_unique(array_filter(array_map('intval', $query->distinct(true)->column('scope_a.doctor_id')))));
|
||||
if (!$isTeamRole && $isDoctor) {
|
||||
$doctorIds[] = $this->adminId;
|
||||
}
|
||||
|
||||
return $this->activeDoctorIds(array_values(array_unique($doctorIds)));
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private function currentRoleIds(): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', AdminRole::where('admin_id', $this->adminId)->column('role_id')))));
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @return int[] */
|
||||
private function activeDoctorIds(array $doctorIds): array
|
||||
{
|
||||
$doctorIds = array_values(array_unique(array_filter(array_map('intval', $doctorIds))));
|
||||
if ($doctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$roleDoctorIds = array_values(array_unique(array_map('intval', AdminRole::whereIn('admin_id', $doctorIds)
|
||||
->where('role_id', 1)
|
||||
->column('admin_id'))));
|
||||
if ($roleDoctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$activeSet = array_fill_keys(array_map('intval', Admin::whereIn('id', $roleDoctorIds)
|
||||
->where('disable', 0)
|
||||
->column('id')), true);
|
||||
|
||||
return array_values(array_filter($doctorIds, static function (int $doctorId) use ($activeSet): bool {
|
||||
return isset($activeSet[$doctorId]);
|
||||
}));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function emptyScheduleDay(string $date): array
|
||||
{
|
||||
$weekdayLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
$timestamp = strtotime($date) ?: time();
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'date_text' => date('m-d', $timestamp),
|
||||
'weekday' => $weekdayLabels[(int) date('w', $timestamp)],
|
||||
'total_slots' => 0,
|
||||
'booked_slots' => 0,
|
||||
'empty_slots' => 0,
|
||||
'doctor_count' => 0,
|
||||
'doctors' => [],
|
||||
'total_appointments' => 0,
|
||||
'waiting_appointments' => 0,
|
||||
'completed_appointments' => 0,
|
||||
'missed_appointments' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function queueStatus(int $status, bool $confirmed, int $aheadCount): string
|
||||
{
|
||||
if ($status === 3) {
|
||||
return 'completed';
|
||||
}
|
||||
if ($status === 4) {
|
||||
return 'missed';
|
||||
}
|
||||
if ($confirmed) {
|
||||
return 'consulting';
|
||||
}
|
||||
|
||||
return $aheadCount === 0 ? 'next' : 'waiting';
|
||||
}
|
||||
|
||||
private function queueStatusText(string $status): string
|
||||
{
|
||||
return [
|
||||
'completed' => '已完成',
|
||||
'missed' => '已过号',
|
||||
'consulting' => '就诊中',
|
||||
'next' => '待确认',
|
||||
'waiting' => '等待中',
|
||||
][$status] ?? '等待中';
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
return preg_replace('/^(\d{3})\d{4}(\d{4})$/', '$1****$2', $phone) ?: $phone;
|
||||
}
|
||||
|
||||
private function appointmentTimeText(array $row): string
|
||||
{
|
||||
$time = trim((string) ($row['appointment_time'] ?? ''));
|
||||
if (strlen($time) > 5) {
|
||||
$time = substr($time, 0, 5);
|
||||
}
|
||||
|
||||
return trim((string) ($row['appointment_date'] ?? '') . ' ' . $time);
|
||||
}
|
||||
|
||||
private function appointmentStatusText(int $status): string
|
||||
{
|
||||
return [1 => '已挂号', 3 => '已完成', 4 => '已过号'][$status] ?? '未知';
|
||||
}
|
||||
|
||||
private function appointmentTypeText(string $type): string
|
||||
{
|
||||
return ['video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊'][$type] ?? '面诊';
|
||||
}
|
||||
|
||||
private function progressText(bool $confirmed, bool $completed, bool $prescribed, int $status): string
|
||||
{
|
||||
if ($status === 4) {
|
||||
return '已过号';
|
||||
}
|
||||
if (!$confirmed) {
|
||||
return '待确认诊单';
|
||||
}
|
||||
if (!$completed) {
|
||||
return '待完诊';
|
||||
}
|
||||
|
||||
return $prescribed ? '已开方' : '待开方';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\ConversionLogic;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\stats\PersonalYeji;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 一诊「综合数据转化」。
|
||||
*
|
||||
* 自动指标复用 ConversionLogic;开口数来自个人业绩录入。所有筛选先与 DataScope
|
||||
* 可见管理员集合取交集,HTTP 参数不能扩大当前账号的数据范围。
|
||||
*/
|
||||
class FirstVisitConversionLogic
|
||||
{
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
[$startDate, $endDate, $timeType, $timeLabel] = self::resolveTimeRange((string) ($params['time_type'] ?? 'today'));
|
||||
$baseVisibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
|
||||
|
||||
$deptSelectionValid = $selectedDeptId <= 0
|
||||
|| $allowedDeptSet === null
|
||||
|| isset($allowedDeptSet[$selectedDeptId]);
|
||||
$selectedDeptIds = [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$selectedDeptIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
DeptLogic::getSelfAndDescendantIds($selectedDeptId)
|
||||
), static fn (int $id): bool => $id > 0)));
|
||||
if ($allowedDeptSet !== null) {
|
||||
$selectedDeptIds = array_values(array_filter(
|
||||
$selectedDeptIds,
|
||||
static fn (int $id): bool => isset($allowedDeptSet[$id])
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveAdminIds = $deptSelectionValid ? $baseVisibleAdminIds : [];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$deptAdminIds = $selectedDeptIds === []
|
||||
? []
|
||||
: self::normalizeIds(AdminDept::whereIn('dept_id', $selectedDeptIds)->column('admin_id'));
|
||||
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
|
||||
}
|
||||
|
||||
if ($selectedAssistantId > 0) {
|
||||
$assistantValid = self::isActiveAssistant($selectedAssistantId)
|
||||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||||
$effectiveAdminIds = $assistantValid ? [$selectedAssistantId] : [];
|
||||
}
|
||||
$costAllocationAdminIds = self::costAllocationAdminIds(
|
||||
$effectiveAdminIds,
|
||||
$scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0
|
||||
);
|
||||
|
||||
$conversionParams = [
|
||||
'dimension' => 'dept',
|
||||
'time_type' => 'custom',
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'include_filters' => 0,
|
||||
'include_members' => 0,
|
||||
'page_no' => 1,
|
||||
'page_size' => 100,
|
||||
];
|
||||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||||
$conversionParams['dept_id'] = $selectedDeptId;
|
||||
}
|
||||
|
||||
$conversion = ConversionLogic::overview(
|
||||
$conversionParams,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$effectiveAdminIds,
|
||||
$costAllocationAdminIds
|
||||
);
|
||||
$rows = is_array($conversion['lists'] ?? null) ? $conversion['lists'] : [];
|
||||
$rowAllowedDeptIds = self::visibleRowDeptIds($effectiveAdminIds);
|
||||
if ($rowAllowedDeptIds !== null) {
|
||||
$rows = self::filterDeptRows($rows, array_fill_keys($rowAllowedDeptIds, true));
|
||||
}
|
||||
|
||||
$rowDeptIdSet = [];
|
||||
self::collectRowDeptIds($rows, $rowDeptIdSet);
|
||||
$openDirect = self::loadOpenCountByDept(
|
||||
$startDate,
|
||||
$endDate,
|
||||
$effectiveAdminIds,
|
||||
array_fill_keys(array_keys($rowDeptIdSet), true)
|
||||
);
|
||||
self::applyOpenCounts($rows, $openDirect);
|
||||
|
||||
$summary = is_array($conversion['summary'] ?? null) ? $conversion['summary'] : [];
|
||||
$summary['total_open_count'] = array_sum($openDirect);
|
||||
$summary['open_receive_rate'] = self::percent(
|
||||
(int) ($summary['completed_order_count'] ?? 0),
|
||||
(int) $summary['total_open_count']
|
||||
);
|
||||
|
||||
$rankingRows = self::rankingRows($rows);
|
||||
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
|
||||
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
|
||||
? []
|
||||
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
|
||||
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = $selectedAssistantId > 0
|
||||
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'time_type' => $timeType,
|
||||
'time_label' => $timeLabel,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_assistant_name' => $selectedAssistantName,
|
||||
'open_count_source' => '个人业绩录入',
|
||||
],
|
||||
'filters' => [
|
||||
'departments' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
|
||||
'assistants' => self::assistantOptions($baseVisibleAdminIds, $selectedDeptIds, $selectedDeptId),
|
||||
],
|
||||
'summary' => $summary,
|
||||
'rankings' => [
|
||||
'orders' => self::topRows($rankingRows, 'completed_order_count'),
|
||||
'amounts' => self::topRows($rankingRows, 'completed_order_amount'),
|
||||
],
|
||||
'rows' => $rows,
|
||||
'target' => $target,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string,2:string,3:string} */
|
||||
private static function resolveTimeRange(string $timeType): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$timeType = in_array($timeType, ['today', 'week', 'month', 'quarter', 'year'], true)
|
||||
? $timeType
|
||||
: 'today';
|
||||
|
||||
if ($timeType === 'week') {
|
||||
return [date('Y-m-d', strtotime('monday this week')), $today, $timeType, '本周'];
|
||||
}
|
||||
if ($timeType === 'month') {
|
||||
return [date('Y-m-01'), $today, $timeType, '本月'];
|
||||
}
|
||||
if ($timeType === 'quarter') {
|
||||
$quarterMonth = ((int) floor(((int) date('n') - 1) / 3) * 3) + 1;
|
||||
|
||||
return [date('Y-' . str_pad((string) $quarterMonth, 2, '0', STR_PAD_LEFT) . '-01'), $today, $timeType, '本季度'];
|
||||
}
|
||||
if ($timeType === 'year') {
|
||||
return [date('Y-01-01'), $today, $timeType, '本年'];
|
||||
}
|
||||
|
||||
return [$today, $today, 'today', '今日'];
|
||||
}
|
||||
|
||||
/** @param int[]|null $visibleIds @param int[] $candidateIds @return int[]|null */
|
||||
private static function intersectVisibleIds(?array $visibleIds, array $candidateIds): ?array
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return $candidateIds;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($visibleIds, $candidateIds));
|
||||
}
|
||||
|
||||
private static function isActiveAssistant(int $adminId): bool
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Db::name('admin')
|
||||
->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('a.id', $adminId)
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time')
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
/** @param int[]|null $visibleAdminIds @return int[]|null */
|
||||
private static function visibleRowDeptIds(?array $visibleAdminIds): ?array
|
||||
{
|
||||
if ($visibleAdminIds === null) {
|
||||
return null;
|
||||
}
|
||||
if ($visibleAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::normalizeIds(AdminDept::whereIn('admin_id', $visibleAdminIds)->column('dept_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人指标仍只查本人;成本按本人所在部门全员的加粉占比分摊。
|
||||
*
|
||||
* @param int[]|null $effectiveAdminIds
|
||||
* @return int[]|null null 表示使用默认分摊范围
|
||||
*/
|
||||
private static function costAllocationAdminIds(?array $effectiveAdminIds, bool $personalScope): ?array
|
||||
{
|
||||
if (!$personalScope) {
|
||||
return null;
|
||||
}
|
||||
if ($effectiveAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
$deptIds = self::visibleRowDeptIds($effectiveAdminIds);
|
||||
if ($deptIds === null || $deptIds === []) {
|
||||
return $effectiveAdminIds ?? [];
|
||||
}
|
||||
|
||||
$ids = self::normalizeIds(AdminDept::whereIn('dept_id', $deptIds)->column('admin_id'));
|
||||
|
||||
return $ids !== [] ? $ids : ($effectiveAdminIds ?? []);
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @param array<int,true> $allowedSet @return array<int,array<string,mixed>> */
|
||||
private static function filterDeptRows(array $rows, array $allowedSet): array
|
||||
{
|
||||
if ($allowedSet === []) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$children = self::filterDeptRows(is_array($row['children'] ?? null) ? $row['children'] : [], $allowedSet);
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if (isset($allowedSet[$id])) {
|
||||
$row['children'] = $children;
|
||||
if ($children === []) {
|
||||
unset($row['children']);
|
||||
}
|
||||
$out[] = $row;
|
||||
continue;
|
||||
}
|
||||
foreach ($children as $child) {
|
||||
$out[] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @param array<int,true> $set */
|
||||
private static function collectRowDeptIds(array $rows, array &$set): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id !== 0) {
|
||||
$set[$id] = true;
|
||||
}
|
||||
self::collectRowDeptIds(is_array($row['children'] ?? null) ? $row['children'] : [], $set);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param int[]|null $effectiveAdminIds @param array<int,true> $rowDeptSet @return array<int,int> */
|
||||
private static function loadOpenCountByDept(string $startDate, string $endDate, ?array $effectiveAdminIds, array $rowDeptSet): array
|
||||
{
|
||||
if ($effectiveAdminIds === [] || $rowDeptSet === []) {
|
||||
return [];
|
||||
}
|
||||
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
|
||||
if ($effectiveAdminIds !== null) {
|
||||
$query->whereIn('creator_id', $effectiveAdminIds);
|
||||
}
|
||||
$rows = $query
|
||||
->fieldRaw('creator_id, SUM(total_open_count) AS open_count')
|
||||
->group('creator_id')
|
||||
->select()
|
||||
->toArray();
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$creatorIds = self::normalizeIds(array_column($rows, 'creator_id'));
|
||||
$deptRows = $creatorIds === [] ? [] : AdminDept::whereIn('admin_id', $creatorIds)
|
||||
->field('admin_id, dept_id')
|
||||
->order('admin_id', 'asc')
|
||||
->order('dept_id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$adminDeptMap = [];
|
||||
foreach ($deptRows as $deptRow) {
|
||||
$adminDeptMap[(int) $deptRow['admin_id']][] = (int) $deptRow['dept_id'];
|
||||
}
|
||||
|
||||
$direct = [];
|
||||
foreach ($rows as $row) {
|
||||
$adminId = (int) ($row['creator_id'] ?? 0);
|
||||
$targetDeptId = 0;
|
||||
foreach ($adminDeptMap[$adminId] ?? [] as $deptId) {
|
||||
if (isset($rowDeptSet[$deptId])) {
|
||||
$targetDeptId = $deptId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($targetDeptId === 0 && isset($rowDeptSet[-2])) {
|
||||
$targetDeptId = -2;
|
||||
}
|
||||
if ($targetDeptId !== 0) {
|
||||
$direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + (int) ($row['open_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $direct;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @param array<int,int> $direct */
|
||||
private static function applyOpenCounts(array &$rows, array $direct): int
|
||||
{
|
||||
$sum = 0;
|
||||
foreach ($rows as &$row) {
|
||||
$children = is_array($row['children'] ?? null) ? $row['children'] : [];
|
||||
$childTotal = self::applyOpenCounts($children, $direct);
|
||||
if ($children !== []) {
|
||||
$row['children'] = $children;
|
||||
}
|
||||
$count = (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal;
|
||||
$row['total_open_count'] = $count;
|
||||
$row['open_receive_rate'] = self::percent((int) ($row['completed_order_count'] ?? 0), $count);
|
||||
$sum += (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $sum;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function rankingRows(array $rows): array
|
||||
{
|
||||
if (count($rows) === 1 && is_array($rows[0]['children'] ?? null) && $rows[0]['children'] !== []) {
|
||||
return $rows[0]['children'];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
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));
|
||||
usort($rows, static function (array $left, array $right) use ($metric): int {
|
||||
return (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
|
||||
});
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => round((float) ($row[$metric] ?? 0), 2),
|
||||
], array_slice($rows, 0, 6));
|
||||
}
|
||||
|
||||
/** @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
|
||||
{
|
||||
$query = Db::name('admin')
|
||||
->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time');
|
||||
if ($baseVisibleAdminIds !== null) {
|
||||
if ($baseVisibleAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query->whereIn('a.id', $baseVisibleAdminIds);
|
||||
}
|
||||
if ($selectedDeptId > 0) {
|
||||
if ($selectedDeptIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query->join('admin_dept ad', 'ad.admin_id = a.id')->whereIn('ad.dept_id', $selectedDeptIds);
|
||||
}
|
||||
|
||||
return $query->field('a.id, a.name')->distinct(true)->order('a.name', 'asc')->select()->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,true>|null $allowedDeptSet @param int[] $selectedDeptIds @return int[]|null */
|
||||
private static function resolveTargetDeptIds(?array $allowedDeptSet, array $selectedDeptIds, int $selectedDeptId): ?array
|
||||
{
|
||||
if ($selectedDeptId > 0) {
|
||||
return $selectedDeptIds;
|
||||
}
|
||||
if ($allowedDeptSet === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_map('intval', array_keys($allowedDeptSet));
|
||||
}
|
||||
|
||||
/** @param int[]|null $effectiveAdminIds @param int[]|null $targetDeptIds @return array<string,mixed> */
|
||||
private static function buildTargetProgress(int $year, ?array $effectiveAdminIds, ?array $targetDeptIds): array
|
||||
{
|
||||
$targetQuery = Db::name('dept_performance_target')->whereLike('year_month', $year . '-%');
|
||||
if ($targetDeptIds !== null) {
|
||||
if ($targetDeptIds === []) {
|
||||
$targetRows = [];
|
||||
} else {
|
||||
$targetRows = $targetQuery->whereIn('dept_id', $targetDeptIds)
|
||||
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
|
||||
->group('`year_month`')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
} else {
|
||||
$targetRows = $targetQuery
|
||||
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
|
||||
->group('`year_month`')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$actualRows = [];
|
||||
if ($effectiveAdminIds !== []) {
|
||||
$actualQuery = Db::name('tcm_prescription_order')
|
||||
->alias('po')
|
||||
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.prescription_audit_status', 1)
|
||||
->where('po.payment_slip_audit_status', 1)
|
||||
->where('po.create_time', 'between', [
|
||||
strtotime($year . '-01-01 00:00:00'),
|
||||
strtotime($year . '-12-31 23:59:59'),
|
||||
]);
|
||||
if ($effectiveAdminIds !== null) {
|
||||
$actualQuery->whereIn('rx.assistant_id', $effectiveAdminIds);
|
||||
}
|
||||
$actualRows = $actualQuery
|
||||
->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount")
|
||||
->group('month_no')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$targets = array_fill(1, 12, 0.0);
|
||||
$actuals = array_fill(1, 12, 0.0);
|
||||
$deptCountSet = [];
|
||||
foreach ($targetRows as $row) {
|
||||
$month = (int) substr((string) ($row['year_month'] ?? ''), 5, 2);
|
||||
if ($month >= 1 && $month <= 12) {
|
||||
$targets[$month] = round((float) ($row['target_amount'] ?? 0), 2);
|
||||
$deptCountSet[$month] = (int) ($row['dept_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
foreach ($actualRows as $row) {
|
||||
$month = (int) ($row['month_no'] ?? 0);
|
||||
if ($month >= 1 && $month <= 12) {
|
||||
$actuals[$month] = round((float) ($row['actual_amount'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
|
||||
$targetCumulative = [];
|
||||
$actualCumulative = [];
|
||||
$targetRunning = 0.0;
|
||||
$actualRunning = 0.0;
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$targetRunning = round($targetRunning + $targets[$month], 2);
|
||||
$actualRunning = round($actualRunning + $actuals[$month], 2);
|
||||
$targetCumulative[] = $targetRunning;
|
||||
$actualCumulative[] = $actualRunning;
|
||||
}
|
||||
$currentMonth = (int) date('n');
|
||||
|
||||
return [
|
||||
'year' => $year,
|
||||
'target_amount' => $targetRunning,
|
||||
'actual_amount' => $actualRunning,
|
||||
'completion_rate' => $targetRunning > 0 ? round($actualRunning / $targetRunning * 100, 2) : null,
|
||||
'current_month_target' => $targets[$currentMonth],
|
||||
'current_month_actual' => $actuals[$currentMonth],
|
||||
'current_month_rate' => $targets[$currentMonth] > 0
|
||||
? round($actuals[$currentMonth] / $targets[$currentMonth] * 100, 2)
|
||||
: null,
|
||||
'department_count' => max($deptCountSet ?: [0]),
|
||||
'months' => array_map(static fn (int $month): string => str_pad((string) $month, 2, '0', STR_PAD_LEFT) . '月', range(1, 12)),
|
||||
'target_cumulative' => $targetCumulative,
|
||||
'actual_cumulative' => $actualCumulative,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int|string,mixed> $ids @return int[] */
|
||||
private static function normalizeIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
|
||||
}
|
||||
|
||||
private static function percent(int $numerator, int $denominator): float
|
||||
{
|
||||
return $denominator > 0 ? round($numerator / $denominator * 100, 2) : 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\DoctorDailyStatsLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 一诊「医生看板」。
|
||||
*
|
||||
* 医生是最终展示维度;部门权限通过实际经手医助下推到挂号、诊单与业绩:
|
||||
* - 医生 SELF:只看本人医生数据,不限制经手医助;
|
||||
* - 医助 SELF:只看本人经手患者关联的医生数据;
|
||||
* - 组长/经理:只看数据范围内医助经手患者关联的医生数据;
|
||||
* - 管理员/ALL:全部医生,可再选择部门收窄。
|
||||
*/
|
||||
class FirstVisitDoctorDashboardLogic
|
||||
{
|
||||
private const DOCTOR_ROLE_ID = 1;
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
private const TREND_DAYS = 30;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$range = self::resolveRange((string) ($params['time_type'] ?? 'month'));
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$roleIds = self::normalizeIds(Db::name('admin_role')->where('admin_id', $adminId)->column('role_id'));
|
||||
$isRoot = (int) ($adminInfo['root'] ?? 0) === 1;
|
||||
$doctorSelf = !$isRoot
|
||||
&& $scopeValue === DataScopeService::SCOPE_SELF
|
||||
&& in_array(self::DOCTOR_ROLE_ID, $roleIds, true);
|
||||
$activeOnly = (int) ($params['active_only'] ?? 1) !== 0;
|
||||
$selectedDeptId = $doctorSelf ? 0 : max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedDoctorId = max(0, (int) ($params['doctor_id'] ?? 0));
|
||||
$threshold = min(100.0, max(1.0, (float) ($params['alert_threshold'] ?? 15)));
|
||||
|
||||
$allDoctorOptions = self::doctorOptions($activeOnly, $doctorSelf ? $adminId : 0);
|
||||
$doctorIds = self::normalizeIds(array_column($allDoctorOptions, 'id'));
|
||||
if ($selectedDoctorId > 0) {
|
||||
$doctorIds = in_array($selectedDoctorId, $doctorIds, true) ? [$selectedDoctorId] : [];
|
||||
}
|
||||
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
[$selectedDeptIds, $deptSelectionValid] = self::resolveSelectedDeptIds(
|
||||
$selectedDeptId,
|
||||
$allowedDeptSet
|
||||
);
|
||||
$assistantIds = self::resolveAssistantScope(
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$doctorSelf,
|
||||
$selectedDeptId,
|
||||
$selectedDeptIds,
|
||||
$deptSelectionValid
|
||||
);
|
||||
|
||||
$stats = DoctorDailyStatsLogic::overview(
|
||||
[
|
||||
'start_date' => $range['start'],
|
||||
'end_date' => $range['end'],
|
||||
],
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$doctorIds,
|
||||
$assistantIds
|
||||
);
|
||||
|
||||
$doctorDeptNames = self::doctorDepartmentNames($doctorIds);
|
||||
$doctorStatus = self::doctorStatusMap($doctorIds);
|
||||
$rows = self::enrichRows(
|
||||
is_array($stats['rows'] ?? null) ? $stats['rows'] : [],
|
||||
$doctorDeptNames,
|
||||
$doctorStatus
|
||||
);
|
||||
$summary = self::buildSummary($rows);
|
||||
$trend = self::buildAmountTrend($doctorIds, $assistantIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedDoctorName = '';
|
||||
if ($selectedDoctorId > 0) {
|
||||
foreach ($allDoctorOptions as $doctor) {
|
||||
if ((int) ($doctor['id'] ?? 0) === $selectedDoctorId) {
|
||||
$selectedDoctorName = (string) ($doctor['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'time_type' => $range['type'],
|
||||
'time_label' => $range['label'],
|
||||
'start_date' => $range['start'],
|
||||
'end_date' => $range['end'],
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => $doctorSelf ? '医生本人' : DataScopeService::scopeLabel($scopeValue),
|
||||
'scope_kind' => $doctorSelf ? 'doctor_self' : ($assistantIds === null ? 'all' : 'assistant_scope'),
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_doctor_name' => $selectedDoctorName,
|
||||
'doctor_count' => count($rows),
|
||||
'appointment_rule' => '总挂号包含已预约、已取消、已完成和已过号;面诊取状态为已完成的挂号',
|
||||
'performance_rule' => '诊单按订单创建时间统计,排除履约已取消、拒收和退款,金额归属处方开方医生',
|
||||
],
|
||||
'filters' => [
|
||||
'departments' => $doctorSelf ? [] : DeptLogic::getAllDataScoped($adminId, $adminInfo),
|
||||
'doctors' => $allDoctorOptions,
|
||||
'can_filter_department' => !$doctorSelf,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'rankings' => [
|
||||
'amounts' => self::ranking($rows, 'deal_amount', 8),
|
||||
'conversion' => self::ranking($rows, 'receive_conversion_rate', 8),
|
||||
],
|
||||
'funnel' => [
|
||||
['key' => 'appointment', 'label' => '挂号', 'value' => (int) $summary['appointment_total']],
|
||||
['key' => 'interview', 'label' => '面诊', 'value' => (int) $summary['interview_count']],
|
||||
['key' => 'receive', 'label' => '接诊', 'value' => (int) $summary['order_count']],
|
||||
['key' => 'deal', 'label' => '成交', 'value' => (int) $summary['order_count']],
|
||||
],
|
||||
'trend' => $trend,
|
||||
'alerts' => self::alertRows($rows, $threshold),
|
||||
'alert_threshold' => $threshold,
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private static function resolveRange(string $type): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
if ($type === 'today') {
|
||||
return ['type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today];
|
||||
}
|
||||
if ($type === 'week') {
|
||||
return [
|
||||
'type' => 'week', 'label' => '本周',
|
||||
'start' => date('Y-m-d', strtotime('monday this week')), 'end' => $today,
|
||||
];
|
||||
}
|
||||
|
||||
return ['type' => 'month', 'label' => '本月', 'start' => date('Y-m-01'), 'end' => $today];
|
||||
}
|
||||
|
||||
/** @return array<int,array{id:int,name:string,disable:int}> */
|
||||
private static function doctorOptions(bool $activeOnly, int $selfDoctorId = 0): array
|
||||
{
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('ar.role_id', self::DOCTOR_ROLE_ID)
|
||||
->whereNull('a.delete_time');
|
||||
if ($activeOnly) {
|
||||
$query->where('a.disable', 0);
|
||||
}
|
||||
if ($selfDoctorId > 0) {
|
||||
$query->where('a.id', $selfDoctorId);
|
||||
}
|
||||
|
||||
return $query->field('a.id, a.name, a.disable')
|
||||
->distinct(true)
|
||||
->order('a.disable', 'asc')
|
||||
->order('a.name', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,true>|null $allowedSet @return array{0:array<int>,1:bool} */
|
||||
private static function resolveSelectedDeptIds(int $selectedDeptId, ?array $allowedSet): array
|
||||
{
|
||||
if ($selectedDeptId <= 0) {
|
||||
return [[], true];
|
||||
}
|
||||
$ids = self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId));
|
||||
if ($allowedSet !== null) {
|
||||
$ids = array_values(array_filter($ids, static fn (int $id): bool => isset($allowedSet[$id])));
|
||||
}
|
||||
|
||||
return [$ids, $ids !== []];
|
||||
}
|
||||
|
||||
/**
|
||||
* null 表示医生本人或 ALL,不附加医助过滤;数组表示必须按这些医助经手的数据收窄。
|
||||
*
|
||||
* @param int[] $selectedDeptIds
|
||||
* @return int[]|null
|
||||
*/
|
||||
private static function resolveAssistantScope(
|
||||
int $adminId,
|
||||
array $adminInfo,
|
||||
bool $doctorSelf,
|
||||
int $selectedDeptId,
|
||||
array $selectedDeptIds,
|
||||
bool $deptSelectionValid
|
||||
): ?array {
|
||||
if ($doctorSelf) {
|
||||
return null;
|
||||
}
|
||||
if (!$deptSelectionValid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$assistantIds = self::activeAssistantIds($visibleIds);
|
||||
if ($selectedDeptId <= 0) {
|
||||
return $visibleIds === null ? null : $assistantIds;
|
||||
}
|
||||
|
||||
$deptAssistantIds = self::activeAssistantIdsByDepartment($selectedDeptIds);
|
||||
if ($visibleIds === null) {
|
||||
return $deptAssistantIds;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($assistantIds, $deptAssistantIds));
|
||||
}
|
||||
|
||||
/** @param int[]|null $visibleIds @return int[] */
|
||||
private static function activeAssistantIds(?array $visibleIds): array
|
||||
{
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time');
|
||||
if ($visibleIds !== null) {
|
||||
$query->whereIn('a.id', $visibleIds);
|
||||
}
|
||||
|
||||
return self::normalizeIds($query->column('a.id'));
|
||||
}
|
||||
|
||||
/** @param int[] $deptIds @return int[] */
|
||||
private static function activeAssistantIdsByDepartment(array $deptIds): array
|
||||
{
|
||||
if ($deptIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::normalizeIds(Db::name('admin')->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->join('admin_dept ad', 'ad.admin_id = a.id')
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->whereIn('ad.dept_id', $deptIds)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time')
|
||||
->distinct(true)
|
||||
->column('a.id'));
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @return array<int,string> */
|
||||
private static function doctorDepartmentNames(array $doctorIds): array
|
||||
{
|
||||
if ($doctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = AdminDept::alias('ad')
|
||||
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL')
|
||||
->whereIn('ad.admin_id', $doctorIds)
|
||||
->field('ad.admin_id, d.name')
|
||||
->order('d.sort', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['admin_id'] ?? 0);
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($id > 0 && $name !== '' && !isset($out[$id])) {
|
||||
$out[$id] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @return array<int,int> */
|
||||
private static function doctorStatusMap(array $doctorIds): array
|
||||
{
|
||||
if ($doctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('admin')->whereIn('id', $doctorIds)->field('id, disable')->select()->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[(int) $row['id']] = (int) ($row['disable'] ?? 0);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function enrichRows(array $rows, array $deptNames, array $statusMap): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['admin_id'] ?? 0);
|
||||
$appointmentTotal = (int) ($row['appointment_total'] ?? 0);
|
||||
$interviewCount = (int) ($row['appointment_completed'] ?? 0);
|
||||
$orderCount = (int) ($row['deal_order_count'] ?? 0);
|
||||
$out[] = array_merge($row, [
|
||||
'doctor_id' => $id,
|
||||
'department_name' => (string) ($deptNames[$id] ?? '未分配部门'),
|
||||
'interview_count' => $interviewCount,
|
||||
'order_count' => $orderCount,
|
||||
'appointment_completion_rate' => $appointmentTotal > 0
|
||||
? round($interviewCount / $appointmentTotal * 100, 2)
|
||||
: null,
|
||||
'receive_conversion_rate' => $interviewCount > 0
|
||||
? round($orderCount / $interviewCount * 100, 2)
|
||||
: null,
|
||||
'status' => (int) ($statusMap[$id] ?? 0) === 0 ? 'active' : 'disabled',
|
||||
]);
|
||||
}
|
||||
usort($out, static fn (array $a, array $b): int => (($b['deal_amount'] ?? 0) <=> ($a['deal_amount'] ?? 0)) ?: strcmp((string) $a['doctor_name'], (string) $b['doctor_name']));
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<string,mixed> */
|
||||
private static function buildSummary(array $rows): array
|
||||
{
|
||||
$appointmentTotal = 0;
|
||||
$interviewCount = 0;
|
||||
$orderCount = 0;
|
||||
$dealAmount = 0.0;
|
||||
$missed = 0;
|
||||
$cancelled = 0;
|
||||
foreach ($rows as $row) {
|
||||
$appointmentTotal += (int) ($row['appointment_total'] ?? 0);
|
||||
$interviewCount += (int) ($row['interview_count'] ?? 0);
|
||||
$orderCount += (int) ($row['order_count'] ?? 0);
|
||||
$dealAmount += (float) ($row['deal_amount'] ?? 0);
|
||||
$missed += (int) ($row['appointment_missed'] ?? 0);
|
||||
$cancelled += (int) ($row['appointment_cancelled'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'appointment_total' => $appointmentTotal,
|
||||
'interview_count' => $interviewCount,
|
||||
'order_count' => $orderCount,
|
||||
'deal_amount' => round($dealAmount, 2),
|
||||
'avg_order_amount' => $orderCount > 0 ? round($dealAmount / $orderCount, 2) : null,
|
||||
'appointment_completion_rate' => $appointmentTotal > 0
|
||||
? round($interviewCount / $appointmentTotal * 100, 2)
|
||||
: null,
|
||||
'receive_conversion_rate' => $interviewCount > 0
|
||||
? round($orderCount / $interviewCount * 100, 2)
|
||||
: null,
|
||||
'missed_count' => $missed,
|
||||
'cancelled_count' => $cancelled,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function ranking(array $rows, string $field, int $limit): array
|
||||
{
|
||||
$ranked = $rows;
|
||||
usort($ranked, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['doctor_name'], (string) $b['doctor_name']));
|
||||
$out = [];
|
||||
foreach (array_slice($ranked, 0, $limit) as $row) {
|
||||
$out[] = [
|
||||
'doctor_id' => (int) ($row['doctor_id'] ?? 0),
|
||||
'name' => (string) ($row['doctor_name'] ?? ''),
|
||||
'value' => round((float) ($row[$field] ?? 0), 2),
|
||||
'interview_count' => (int) ($row['interview_count'] ?? 0),
|
||||
'order_count' => (int) ($row['order_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @param int[]|null $assistantIds @return array<string,mixed> */
|
||||
private static function buildAmountTrend(array $doctorIds, ?array $assistantIds): array
|
||||
{
|
||||
$endDate = date('Y-m-d');
|
||||
$startDate = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
|
||||
$amountByDate = [];
|
||||
if ($doctorIds !== [] && $assistantIds !== []) {
|
||||
$query = Db::name('tcm_prescription_order')->alias('o')
|
||||
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
|
||||
->whereNull('o.delete_time')
|
||||
->whereIn('rx.creator_id', $doctorIds)
|
||||
->where('o.diagnosis_id', '>', 0)
|
||||
->where('o.create_time', 'between', [
|
||||
strtotime($startDate . ' 00:00:00'),
|
||||
strtotime($endDate . ' 23:59:59'),
|
||||
]);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'o');
|
||||
if ($assistantIds !== null) {
|
||||
$query->whereIn('o.creator_id', $assistantIds);
|
||||
}
|
||||
$rows = $query
|
||||
->fieldRaw("FROM_UNIXTIME(o.create_time, '%Y-%m-%d') AS date_label, SUM(o.amount) AS amount_sum")
|
||||
->group('date_label')
|
||||
->order('date_label', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as $row) {
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($date !== '') {
|
||||
$amountByDate[$date] = round((float) ($row['amount_sum'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
$dates = [];
|
||||
$labels = [];
|
||||
$amounts = [];
|
||||
for ($offset = 0; $offset < self::TREND_DAYS; $offset++) {
|
||||
$date = date('Y-m-d', strtotime($startDate . ' +' . $offset . ' days'));
|
||||
$dates[] = $date;
|
||||
$labels[] = date('m-d', strtotime($date));
|
||||
$amounts[] = (float) ($amountByDate[$date] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'dates' => $dates,
|
||||
'labels' => $labels,
|
||||
'amounts' => $amounts,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function alertRows(array $rows, float $threshold): array
|
||||
{
|
||||
$alerts = array_values(array_filter($rows, static function (array $row) use ($threshold): bool {
|
||||
$interviews = (int) ($row['interview_count'] ?? 0);
|
||||
$rate = $row['receive_conversion_rate'] ?? null;
|
||||
|
||||
return $interviews > 0 && ($rate === null || (float) $rate < $threshold);
|
||||
}));
|
||||
usort($alerts, static fn (array $a, array $b): int => (($a['receive_conversion_rate'] ?? -1) <=> ($b['receive_conversion_rate'] ?? -1)) ?: (($b['interview_count'] ?? 0) <=> ($a['interview_count'] ?? 0)));
|
||||
|
||||
return array_map(static function (array $row) use ($threshold): array {
|
||||
$rate = (float) ($row['receive_conversion_rate'] ?? 0);
|
||||
return [
|
||||
'doctor_id' => (int) ($row['doctor_id'] ?? 0),
|
||||
'doctor_name' => (string) ($row['doctor_name'] ?? ''),
|
||||
'department_name' => (string) ($row['department_name'] ?? ''),
|
||||
'interview_count' => (int) ($row['interview_count'] ?? 0),
|
||||
'order_count' => (int) ($row['order_count'] ?? 0),
|
||||
'rate' => round($rate, 2),
|
||||
'severity' => $rate < $threshold / 2 ? 'high' : 'medium',
|
||||
'suggestion' => (int) ($row['order_count'] ?? 0) === 0
|
||||
? '当前有面诊但无接诊诊单,建议核对诊单及跟进记录'
|
||||
: '接诊转化低于预警线,建议复盘患者需求与沟通记录',
|
||||
];
|
||||
}, $alerts);
|
||||
}
|
||||
|
||||
/** @param array<int|string,mixed> $ids @return int[] */
|
||||
private static function normalizeIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 一诊「挂号统计」。
|
||||
*
|
||||
* 统计口径:
|
||||
* - 挂号:doctor_appointment.appointment_date,状态 1/3/4,排除已取消 2;
|
||||
* 归属优先挂号医助 assistant_id,再回退诊单医助 assistant_id。
|
||||
* - 诊单:tcm_prescription_order.create_time,归属订单 creator_id,排除履约 4/9/10。
|
||||
* - 所有部门和员工筛选都只能收窄 DataScope,不允许 HTTP 参数扩大当前账号范围。
|
||||
*/
|
||||
class FirstVisitRegistrationStatsLogic
|
||||
{
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$range = self::resolveRange((string) ($params['time_type'] ?? 'today'));
|
||||
$baseVisibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
|
||||
|
||||
[$selectedDeptIds, $deptSelectionValid] = self::resolveSelectedDeptIds(
|
||||
$selectedDeptId,
|
||||
$allowedDeptSet
|
||||
);
|
||||
$assistants = $deptSelectionValid
|
||||
? self::assistantOptions($baseVisibleIds, $selectedDeptIds, $selectedDeptId)
|
||||
: [];
|
||||
$assistantIds = self::normalizeIds(array_column($assistants, 'id'));
|
||||
if ($selectedAssistantId > 0) {
|
||||
$assistantIds = in_array($selectedAssistantId, $assistantIds, true)
|
||||
? [$selectedAssistantId]
|
||||
: [];
|
||||
}
|
||||
|
||||
$departmentTree = DeptLogic::getAllDataScoped($adminId, $adminInfo);
|
||||
$departmentIndex = [];
|
||||
self::flattenDepartmentTree($departmentTree, $departmentIndex, 0);
|
||||
$assignment = self::buildAssistantDepartmentMap(
|
||||
$assistantIds,
|
||||
$departmentIndex,
|
||||
$selectedDeptIds,
|
||||
$selectedDeptId
|
||||
);
|
||||
|
||||
$appointmentDaily = self::loadAppointmentDaily(
|
||||
min($range['compare_start'], $range['start']),
|
||||
$range['day_after_tomorrow'],
|
||||
$assistantIds
|
||||
);
|
||||
$orderDaily = self::loadOrderDaily(
|
||||
min($range['compare_start'], $range['start']),
|
||||
$range['end'],
|
||||
$assistantIds
|
||||
);
|
||||
|
||||
$members = self::buildMemberRows(
|
||||
$assistants,
|
||||
$assistantIds,
|
||||
$assignment,
|
||||
$appointmentDaily,
|
||||
$orderDaily,
|
||||
$range
|
||||
);
|
||||
$groups = self::buildDepartmentGroups($members, $departmentIndex);
|
||||
$summary = self::buildSummary($members, $range);
|
||||
$targetDeptIds = self::resolveTargetDeptIds(
|
||||
$adminId,
|
||||
$scopeValue,
|
||||
$selectedAssistantId,
|
||||
$selectedDeptIds,
|
||||
$selectedDeptId
|
||||
);
|
||||
$target = self::buildTarget((int) date('Y'), $assistantIds, $targetDeptIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0
|
||||
? (string) ($departmentIndex[$selectedDeptId]['name'] ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = '';
|
||||
if ($selectedAssistantId > 0) {
|
||||
foreach ($assistants as $assistant) {
|
||||
if ((int) ($assistant['id'] ?? 0) === $selectedAssistantId) {
|
||||
$selectedAssistantName = (string) ($assistant['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'time_type' => $range['type'],
|
||||
'time_label' => $range['label'],
|
||||
'start_date' => $range['start'],
|
||||
'end_date' => $range['end'],
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_assistant_name' => $selectedAssistantName,
|
||||
'member_count' => count($assistantIds),
|
||||
'appointment_rule' => '预约日期在统计区间,状态为已预约、已完成或已过号,排除已取消',
|
||||
'performance_rule' => '按订单创建时间和创建人统计,排除已取消、拒收和退款',
|
||||
],
|
||||
'filters' => [
|
||||
'departments' => $departmentTree,
|
||||
'assistants' => $assistants,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'employee_rows' => $groups,
|
||||
'rankings' => [
|
||||
'performance' => self::rankMembers($members, 'order_amount', 10),
|
||||
'appointments' => self::rankMembers($members, 'appointment_count', 10),
|
||||
],
|
||||
'departments' => self::departmentSummaryRows($groups),
|
||||
'target' => $target,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private static function resolveRange(string $type): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$tomorrow = date('Y-m-d', strtotime('+1 day'));
|
||||
$dayAfterTomorrow = date('Y-m-d', strtotime('+2 days'));
|
||||
if ($type === 'week') {
|
||||
$start = date('Y-m-d', strtotime('monday this week'));
|
||||
|
||||
return [
|
||||
'type' => 'week', 'label' => '本周', 'start' => $start, 'end' => $today,
|
||||
'compare_start' => date('Y-m-d', strtotime($start . ' -7 days')),
|
||||
'compare_end' => date('Y-m-d', strtotime($today . ' -7 days')),
|
||||
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
|
||||
];
|
||||
}
|
||||
if ($type === 'month') {
|
||||
$start = date('Y-m-01');
|
||||
$previousStart = date('Y-m-01', strtotime('first day of previous month'));
|
||||
$previousLastDay = (int) date('t', strtotime($previousStart));
|
||||
$day = min((int) date('j'), $previousLastDay);
|
||||
|
||||
return [
|
||||
'type' => 'month', 'label' => '本月', 'start' => $start, 'end' => $today,
|
||||
'compare_start' => $previousStart,
|
||||
'compare_end' => date('Y-m-d', strtotime($previousStart . ' +' . max(0, $day - 1) . ' days')),
|
||||
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today,
|
||||
'compare_start' => date('Y-m-d', strtotime('-1 day')),
|
||||
'compare_end' => date('Y-m-d', strtotime('-1 day')),
|
||||
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,true>|null $allowedSet @return array{0:array<int>,1:bool} */
|
||||
private static function resolveSelectedDeptIds(int $selectedDeptId, ?array $allowedSet): array
|
||||
{
|
||||
if ($selectedDeptId <= 0) {
|
||||
return [[], true];
|
||||
}
|
||||
$ids = self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId));
|
||||
if ($allowedSet !== null) {
|
||||
$ids = array_values(array_filter($ids, static fn (int $id): bool => isset($allowedSet[$id])));
|
||||
}
|
||||
|
||||
return [$ids, $ids !== []];
|
||||
}
|
||||
|
||||
/** @param int[]|null $visibleIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
|
||||
private static function assistantOptions(?array $visibleIds, array $selectedDeptIds, int $selectedDeptId): array
|
||||
{
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time');
|
||||
if ($visibleIds !== null) {
|
||||
$query->whereIn('a.id', $visibleIds);
|
||||
}
|
||||
if ($selectedDeptId > 0) {
|
||||
if ($selectedDeptIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query->join('admin_dept ad', 'ad.admin_id = a.id')
|
||||
->whereIn('ad.dept_id', $selectedDeptIds);
|
||||
}
|
||||
|
||||
return $query->field('a.id, a.name')->distinct(true)->order('a.name', 'asc')->select()->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $nodes @param array<int,array<string,mixed>> $index */
|
||||
private static function flattenDepartmentTree(array $nodes, array &$index, int $depth): void
|
||||
{
|
||||
foreach ($nodes as $node) {
|
||||
$id = (int) ($node['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$index[$id] = [
|
||||
'id' => $id,
|
||||
'pid' => (int) ($node['pid'] ?? 0),
|
||||
'name' => (string) ($node['name'] ?? '未命名部门'),
|
||||
'sort' => (int) ($node['sort'] ?? 0),
|
||||
'depth' => $depth,
|
||||
];
|
||||
self::flattenDepartmentTree(
|
||||
is_array($node['children'] ?? null) ? $node['children'] : [],
|
||||
$index,
|
||||
$depth + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @param array<int,array<string,mixed>> $deptIndex @param int[] $selectedDeptIds @return array<int,int> */
|
||||
private static function buildAssistantDepartmentMap(
|
||||
array $assistantIds,
|
||||
array $deptIndex,
|
||||
array $selectedDeptIds,
|
||||
int $selectedDeptId
|
||||
): array {
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$allowed = $selectedDeptId > 0 ? array_fill_keys($selectedDeptIds, true) : null;
|
||||
$rows = AdminDept::whereIn('admin_id', $assistantIds)
|
||||
->field('admin_id, dept_id')
|
||||
->select()
|
||||
->toArray();
|
||||
$candidates = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['admin_id'] ?? 0);
|
||||
$deptId = (int) ($row['dept_id'] ?? 0);
|
||||
if (!isset($deptIndex[$deptId]) || ($allowed !== null && !isset($allowed[$deptId]))) {
|
||||
continue;
|
||||
}
|
||||
$candidates[$aid][] = $deptId;
|
||||
}
|
||||
$out = [];
|
||||
foreach ($assistantIds as $aid) {
|
||||
$ids = $candidates[$aid] ?? [];
|
||||
usort($ids, static function (int $left, int $right) use ($deptIndex): int {
|
||||
$depthCompare = (int) ($deptIndex[$right]['depth'] ?? 0) <=> (int) ($deptIndex[$left]['depth'] ?? 0);
|
||||
if ($depthCompare !== 0) {
|
||||
return $depthCompare;
|
||||
}
|
||||
|
||||
return (int) ($deptIndex[$right]['sort'] ?? 0) <=> (int) ($deptIndex[$left]['sort'] ?? 0);
|
||||
});
|
||||
$out[$aid] = (int) ($ids[0] ?? 0);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @return array<int,array<string,array{count:int}>> */
|
||||
private static function loadAppointmentDaily(string $startDate, string $endDate, array $assistantIds): array
|
||||
{
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$effective = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
|
||||
$query = Db::name('doctor_appointment')->alias('a')
|
||||
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||||
->where('a.appointment_date', 'between', [$startDate, $endDate])
|
||||
->whereIn('a.status', [1, 3, 4])
|
||||
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)')
|
||||
->whereRaw("({$effective}) IN (" . implode(',', $assistantIds) . ')');
|
||||
$rows = $query
|
||||
->field([
|
||||
'a.appointment_date AS date_label',
|
||||
Db::raw("({$effective}) AS assistant_id"),
|
||||
Db::raw('COUNT(*) AS item_count'),
|
||||
])
|
||||
->group(['a.appointment_date', $effective])
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($aid > 0 && $date !== '') {
|
||||
$out[$aid][$date] = ['count' => (int) ($row['item_count'] ?? 0)];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @return array<int,array<string,array{count:int,amount:float}>> */
|
||||
private static function loadOrderDaily(string $startDate, string $endDate, array $assistantIds): array
|
||||
{
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query = Db::name('tcm_prescription_order')->alias('po')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.create_time', 'between', [
|
||||
strtotime($startDate . ' 00:00:00'),
|
||||
strtotime($endDate . ' 23:59:59'),
|
||||
])
|
||||
->whereIn('po.creator_id', $assistantIds);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
|
||||
$rows = $query
|
||||
->fieldRaw("po.creator_id AS assistant_id, FROM_UNIXTIME(po.create_time, '%Y-%m-%d') AS date_label, COUNT(*) AS item_count, SUM(po.amount) AS amount_sum")
|
||||
->group(['po.creator_id', 'date_label'])
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($aid > 0 && $date !== '') {
|
||||
$out[$aid][$date] = [
|
||||
'count' => (int) ($row['item_count'] ?? 0),
|
||||
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private static function buildMemberRows(
|
||||
array $assistants,
|
||||
array $assistantIds,
|
||||
array $assignment,
|
||||
array $appointmentDaily,
|
||||
array $orderDaily,
|
||||
array $range
|
||||
): array {
|
||||
$assistantIndex = [];
|
||||
foreach ($assistants as $assistant) {
|
||||
$assistantIndex[(int) ($assistant['id'] ?? 0)] = (string) ($assistant['name'] ?? '未命名员工');
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($assistantIds as $aid) {
|
||||
$appointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
|
||||
$compareAppointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['compare_start'], $range['compare_end'], 'count');
|
||||
$orderCount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
|
||||
$orderAmount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'amount');
|
||||
$rows[] = [
|
||||
'id' => 'admin-' . $aid,
|
||||
'admin_id' => $aid,
|
||||
'dept_id' => (int) ($assignment[$aid] ?? 0),
|
||||
'name' => (string) ($assistantIndex[$aid] ?? '未命名员工'),
|
||||
'row_type' => 'employee',
|
||||
'appointment_count' => (int) $appointmentCount,
|
||||
'compare_appointment_count' => (int) $compareAppointmentCount,
|
||||
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
|
||||
'tomorrow_count' => (int) ($appointmentDaily[$aid][$range['tomorrow']]['count'] ?? 0),
|
||||
'day_after_count' => (int) ($appointmentDaily[$aid][$range['day_after_tomorrow']]['count'] ?? 0),
|
||||
'order_count' => (int) $orderCount,
|
||||
'order_amount' => round((float) $orderAmount, 2),
|
||||
'status' => 'normal',
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int => ($b['appointment_count'] <=> $a['appointment_count']) ?: ($b['order_amount'] <=> $a['order_amount']));
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $members @param array<int,array<string,mixed>> $deptIndex @return array<int,array<string,mixed>> */
|
||||
private static function buildDepartmentGroups(array $members, array $deptIndex): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($members as $member) {
|
||||
$deptId = (int) ($member['dept_id'] ?? 0);
|
||||
$key = $deptId > 0 ? $deptId : -2;
|
||||
if (!isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'id' => 'dept-' . $key,
|
||||
'dept_id' => $key,
|
||||
'name' => $key > 0 ? (string) ($deptIndex[$key]['name'] ?? '未命名部门') : '未分配部门',
|
||||
'row_type' => 'department',
|
||||
'member_count' => 0,
|
||||
'appointment_count' => 0,
|
||||
'compare_appointment_count' => 0,
|
||||
'tomorrow_count' => 0,
|
||||
'day_after_count' => 0,
|
||||
'order_count' => 0,
|
||||
'order_amount' => 0.0,
|
||||
'children' => [],
|
||||
'_sort' => $key > 0 ? (int) ($deptIndex[$key]['sort'] ?? 0) : -1,
|
||||
];
|
||||
}
|
||||
$groups[$key]['children'][] = $member;
|
||||
$groups[$key]['member_count']++;
|
||||
foreach (['appointment_count', 'compare_appointment_count', 'tomorrow_count', 'day_after_count', 'order_count'] as $field) {
|
||||
$groups[$key][$field] += (int) ($member[$field] ?? 0);
|
||||
}
|
||||
$groups[$key]['order_amount'] += (float) ($member['order_amount'] ?? 0);
|
||||
}
|
||||
foreach ($groups as &$group) {
|
||||
$group['order_amount'] = round((float) $group['order_amount'], 2);
|
||||
$group['appointment_compare_rate'] = self::relativeChange(
|
||||
(float) $group['appointment_count'],
|
||||
(float) $group['compare_appointment_count']
|
||||
);
|
||||
$group['status'] = 'normal';
|
||||
}
|
||||
unset($group);
|
||||
$out = array_values($groups);
|
||||
usort($out, static fn (array $a, array $b): int => ($b['_sort'] <=> $a['_sort']) ?: strcmp((string) $a['name'], (string) $b['name']));
|
||||
foreach ($out as &$row) {
|
||||
unset($row['_sort']);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $members @return array<string,mixed> */
|
||||
private static function buildSummary(array $members, array $range): array
|
||||
{
|
||||
$appointmentCount = 0;
|
||||
$compareAppointmentCount = 0;
|
||||
$orderCount = 0;
|
||||
$orderAmount = 0.0;
|
||||
foreach ($members as $member) {
|
||||
$appointmentCount += (int) ($member['appointment_count'] ?? 0);
|
||||
$compareAppointmentCount += (int) ($member['compare_appointment_count'] ?? 0);
|
||||
$orderCount += (int) ($member['order_count'] ?? 0);
|
||||
$orderAmount += (float) ($member['order_amount'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'appointment_count' => $appointmentCount,
|
||||
'appointment_compare_count' => $compareAppointmentCount,
|
||||
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
|
||||
'order_count' => $orderCount,
|
||||
'order_amount' => round($orderAmount, 2),
|
||||
'range_label' => $range['label'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $members @return array<int,array<string,mixed>> */
|
||||
private static function rankMembers(array $members, string $field, int $limit): array
|
||||
{
|
||||
$rows = $members;
|
||||
usort($rows, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['name'], (string) $b['name']));
|
||||
$out = [];
|
||||
foreach (array_slice($rows, 0, $limit) as $row) {
|
||||
$out[] = [
|
||||
'admin_id' => (int) ($row['admin_id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => $field === 'order_amount'
|
||||
? round((float) ($row[$field] ?? 0), 2)
|
||||
: (int) ($row[$field] ?? 0),
|
||||
'count' => $field === 'order_amount' ? (int) ($row['order_count'] ?? 0) : (int) ($row['appointment_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $groups @return array<int,array<string,mixed>> */
|
||||
private static function departmentSummaryRows(array $groups): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($groups as $group) {
|
||||
$copy = $group;
|
||||
unset($copy['children']);
|
||||
$rows[] = $copy;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param int[] $selectedDeptIds @return int[]|null */
|
||||
private static function resolveTargetDeptIds(
|
||||
int $adminId,
|
||||
int $scopeValue,
|
||||
int $selectedAssistantId,
|
||||
array $selectedDeptIds,
|
||||
int $selectedDeptId
|
||||
): ?array {
|
||||
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$scopeDeptIds = null;
|
||||
if ($scopeValue !== DataScopeService::SCOPE_ALL) {
|
||||
$ownDeptIds = self::normalizeIds(AdminDept::where('admin_id', $adminId)->column('dept_id'));
|
||||
if ($scopeValue === DataScopeService::SCOPE_DEPT) {
|
||||
$scopeDeptIds = $ownDeptIds;
|
||||
} else {
|
||||
$set = [];
|
||||
foreach ($ownDeptIds as $deptId) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$set[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$scopeDeptIds = array_map('intval', array_keys($set));
|
||||
}
|
||||
}
|
||||
|
||||
if ($selectedDeptId <= 0) {
|
||||
return $scopeDeptIds;
|
||||
}
|
||||
if ($scopeDeptIds === null) {
|
||||
return $selectedDeptIds;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($scopeDeptIds, $selectedDeptIds));
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @param int[]|null $targetDeptIds @return array<string,mixed> */
|
||||
private static function buildTarget(int $year, array $assistantIds, ?array $targetDeptIds): array
|
||||
{
|
||||
$targetRows = [];
|
||||
if ($targetDeptIds !== []) {
|
||||
$query = Db::name('dept_performance_target')->whereLike('year_month', $year . '-%');
|
||||
if ($targetDeptIds !== null) {
|
||||
$query->whereIn('dept_id', $targetDeptIds);
|
||||
}
|
||||
$targetRows = $query
|
||||
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
|
||||
->group('`year_month`')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$actualRows = [];
|
||||
if ($assistantIds !== []) {
|
||||
$query = Db::name('tcm_prescription_order')->alias('po')
|
||||
->whereNull('po.delete_time')
|
||||
->whereIn('po.creator_id', $assistantIds)
|
||||
->where('po.create_time', 'between', [
|
||||
strtotime($year . '-01-01 00:00:00'),
|
||||
strtotime($year . '-12-31 23:59:59'),
|
||||
]);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
|
||||
$actualRows = $query
|
||||
->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount")
|
||||
->group('month_no')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$monthlyTarget = array_fill(1, 12, 0.0);
|
||||
$monthlyActual = array_fill(1, 12, 0.0);
|
||||
$deptCount = 0;
|
||||
foreach ($targetRows as $row) {
|
||||
$month = (int) substr((string) ($row['year_month'] ?? ''), 5, 2);
|
||||
if ($month >= 1 && $month <= 12) {
|
||||
$monthlyTarget[$month] = round((float) ($row['target_amount'] ?? 0), 2);
|
||||
$deptCount = max($deptCount, (int) ($row['dept_count'] ?? 0));
|
||||
}
|
||||
}
|
||||
foreach ($actualRows as $row) {
|
||||
$month = (int) ($row['month_no'] ?? 0);
|
||||
if ($month >= 1 && $month <= 12) {
|
||||
$monthlyActual[$month] = round((float) ($row['actual_amount'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
$targetCumulative = [];
|
||||
$actualCumulative = [];
|
||||
$targetTotal = 0.0;
|
||||
$actualTotal = 0.0;
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$targetTotal = round($targetTotal + $monthlyTarget[$month], 2);
|
||||
$actualTotal = round($actualTotal + $monthlyActual[$month], 2);
|
||||
$targetCumulative[] = $targetTotal;
|
||||
$actualCumulative[] = $actualTotal;
|
||||
}
|
||||
|
||||
return [
|
||||
'year' => $year,
|
||||
'target_amount' => $targetTotal,
|
||||
'actual_amount' => $actualTotal,
|
||||
'completion_rate' => $targetTotal > 0 ? round($actualTotal / $targetTotal * 100, 2) : null,
|
||||
'department_count' => $deptCount,
|
||||
'scope_note' => $targetDeptIds === [] ? '当前为本人或单个员工范围,未设置个人目标' : '按当前可见部门汇总',
|
||||
'months' => array_map(static fn (int $month): string => str_pad((string) $month, 2, '0', STR_PAD_LEFT) . '月', range(1, 12)),
|
||||
'target_cumulative' => $targetCumulative,
|
||||
'actual_cumulative' => $actualCumulative,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,array<string,int|float>> $daily */
|
||||
private static function sumDaily(array $daily, string $start, string $end, string $field): float
|
||||
{
|
||||
$sum = 0.0;
|
||||
foreach ($daily as $date => $values) {
|
||||
if ($date >= $start && $date <= $end) {
|
||||
$sum += (float) ($values[$field] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $sum;
|
||||
}
|
||||
|
||||
private static function relativeChange(float $current, float $previous): ?float
|
||||
{
|
||||
if (abs($previous) < 0.00001) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(($current - $previous) / $previous * 100, 2);
|
||||
}
|
||||
|
||||
/** @param array<int|string,mixed> $ids @return int[] */
|
||||
private static function normalizeIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* “我的患者”统一数据范围。
|
||||
*
|
||||
* 角色语义:医生只看本人接诊患者,医助只看本人归属患者;经理、
|
||||
* 诊室组长和管理员按系统 DataScope 查看团队患者;root 查看全部。
|
||||
*/
|
||||
class MyPatientLogic
|
||||
{
|
||||
private const DOCTOR_ROLE_ID = 1;
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
private const TEAM_ROLE_IDS = [3, 7, 8];
|
||||
private const EFFECTIVE_APPOINTMENT_STATUSES = [1, 3, 4];
|
||||
|
||||
/**
|
||||
* @param Query $query 以 d 作为 zyt_tcm_diagnosis 别名的查询
|
||||
*/
|
||||
public static function applyScope(Query $query, int $adminId, array $adminInfo): void
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roleIds = self::roleIds($adminId);
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$statusList = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
|
||||
|
||||
// 管理角色按系统的数据范围查看“范围内医助归属或医生接诊”的患者。
|
||||
if (array_intersect($roleIds, self::TEAM_ROLE_IDS) !== []) {
|
||||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleAdminIds === null) {
|
||||
return;
|
||||
}
|
||||
$visibleAdminIds = self::normalizeIds($visibleAdminIds);
|
||||
if ($visibleAdminIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
$ids = implode(',', $visibleAdminIds);
|
||||
$query->whereRaw(
|
||||
"(CAST(d.assistant_id AS UNSIGNED) IN ({$ids})"
|
||||
. " OR EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
|
||||
. ' WHERE scope_apt.patient_id = d.id'
|
||||
. " AND scope_apt.status IN ({$statusList})"
|
||||
. " AND scope_apt.doctor_id IN ({$ids})))"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 一线角色始终只取“本人关系”,不受数据库中医生角色 ALL 配置影响。
|
||||
$conditions = [];
|
||||
if (in_array(self::ASSISTANT_ROLE_ID, $roleIds, true)) {
|
||||
$conditions[] = 'CAST(d.assistant_id AS UNSIGNED) = ' . $adminId;
|
||||
}
|
||||
if (in_array(self::DOCTOR_ROLE_ID, $roleIds, true)) {
|
||||
$conditions[] = "EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
|
||||
. ' WHERE scope_apt.patient_id = d.id'
|
||||
. " AND scope_apt.status IN ({$statusList})"
|
||||
. " AND scope_apt.doctor_id = {$adminId})";
|
||||
}
|
||||
|
||||
// 未知/异常角色按本人医助或本人医生关系收窄,拒绝意外放大全库。
|
||||
if ($conditions === []) {
|
||||
$conditions[] = 'CAST(d.assistant_id AS UNSIGNED) = ' . $adminId;
|
||||
$conditions[] = "EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
|
||||
. ' WHERE scope_apt.patient_id = d.id'
|
||||
. " AND scope_apt.status IN ({$statusList})"
|
||||
. " AND scope_apt.doctor_id = {$adminId})";
|
||||
}
|
||||
|
||||
$query->whereRaw('(' . implode(' OR ', $conditions) . ')');
|
||||
}
|
||||
|
||||
public static function canAccessDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if ($diagnosisId <= 0 || $adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$diagnosisTable = (new Diagnosis())->getTable();
|
||||
$query = Db::table($diagnosisTable)
|
||||
->alias('d')
|
||||
->where('d.id', $diagnosisId)
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1);
|
||||
self::applyScope($query, $adminId, $adminInfo);
|
||||
|
||||
return (int) $query->count() > 0;
|
||||
}
|
||||
|
||||
/** @return array{mode:string,label:string} */
|
||||
public static function scopeMeta(int $adminId, array $adminInfo): array
|
||||
{
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return ['mode' => 'all', 'label' => '全部数据'];
|
||||
}
|
||||
|
||||
$roleIds = self::roleIds($adminId);
|
||||
if (array_intersect($roleIds, self::TEAM_ROLE_IDS) !== []) {
|
||||
$scope = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$labels = [
|
||||
DataScopeService::SCOPE_ALL => '全部数据',
|
||||
DataScopeService::SCOPE_DEPT_AND_CHILD => '本部门及下级',
|
||||
DataScopeService::SCOPE_DEPT => '本部门',
|
||||
DataScopeService::SCOPE_SELF => '仅本人',
|
||||
];
|
||||
|
||||
return [
|
||||
'mode' => $scope === DataScopeService::SCOPE_ALL ? 'all' : 'team',
|
||||
'label' => $labels[$scope] ?? '仅本人',
|
||||
];
|
||||
}
|
||||
|
||||
$isDoctor = in_array(self::DOCTOR_ROLE_ID, $roleIds, true);
|
||||
$isAssistant = in_array(self::ASSISTANT_ROLE_ID, $roleIds, true);
|
||||
if ($isDoctor && $isAssistant) {
|
||||
return ['mode' => 'self', 'label' => '本人归属及接诊'];
|
||||
}
|
||||
if ($isDoctor) {
|
||||
return ['mode' => 'self', 'label' => '本人接诊'];
|
||||
}
|
||||
|
||||
return ['mode' => 'self', 'label' => '本人归属'];
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private static function roleIds(int $adminId): array
|
||||
{
|
||||
return self::normalizeIds(AdminRole::where('admin_id', $adminId)->column('role_id'));
|
||||
}
|
||||
|
||||
/** @param array<int|string, mixed> $ids @return int[] */
|
||||
private static function normalizeIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $id): bool {
|
||||
return $id > 0;
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\qywx\QywxPromotionOpenWorkService;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 一诊 / 企业微信推广助手管理逻辑。 */
|
||||
class WecomPromotionLogic
|
||||
{
|
||||
public static function overview(int $adminId, array $adminInfo, string $domain): array
|
||||
{
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$accountsQuery = Db::name('qywx_promotion_account')->alias('a')
|
||||
->leftJoin('admin u', 'u.id = a.owner_admin_id')
|
||||
->leftJoin('dept d', 'd.id = a.dept_id')
|
||||
->whereNull('a.delete_time');
|
||||
$accounts = $accountsQuery
|
||||
->field('a.id,a.corp_id,a.corp_name,a.agent_id,a.auth_status,a.owner_admin_id,a.dept_id,a.authorized_at,a.last_refresh_at,a.create_time,u.name as owner_name,d.name as dept_name')
|
||||
->order('a.auth_status', 'desc')
|
||||
->order('a.id', 'desc')
|
||||
->select()->toArray();
|
||||
foreach ($accounts as &$account) {
|
||||
$account['corp_id_masked'] = self::mask((string) ($account['corp_id'] ?? ''));
|
||||
unset($account['corp_id']);
|
||||
}
|
||||
unset($account);
|
||||
|
||||
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
|
||||
->leftJoin('admin u', 'u.id = p.owner_admin_id')
|
||||
->leftJoin('dept d', 'd.id = p.dept_id')
|
||||
->whereNull('p.delete_time');
|
||||
self::applyOwnerScope($poolsQuery, 'p', $visibleIds);
|
||||
$pools = $poolsQuery
|
||||
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
|
||||
->order('p.id', 'desc')
|
||||
->select()->toArray();
|
||||
|
||||
$poolIds = array_values(array_filter(array_map('intval', array_column($pools, 'id'))));
|
||||
$links = [];
|
||||
if ($poolIds !== []) {
|
||||
$links = Db::name('qywx_promotion_link')->alias('l')
|
||||
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
|
||||
->whereNull('l.delete_time')
|
||||
->whereIn('l.pool_id', $poolIds)
|
||||
->field('l.id,l.pool_id,l.account_id,l.name,l.group_name,l.wecom_url,l.weight,l.status,l.daily_limit,l.today_count,l.today_date,l.active_start,l.active_end,l.click_count,l.last_click_time,l.remark,l.create_time,l.update_time,a.corp_name,a.auth_status')
|
||||
->order('l.status', 'desc')
|
||||
->order('l.weight', 'desc')
|
||||
->order('l.id', 'desc')
|
||||
->select()->toArray();
|
||||
}
|
||||
|
||||
$domain = rtrim($domain, '/');
|
||||
foreach ($pools as &$pool) {
|
||||
$key = (string) $pool['public_key'];
|
||||
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
|
||||
$goUrl = $domain . '/api/qywx-promotion/go/' . $key;
|
||||
$pool['script_url'] = $scriptUrl;
|
||||
$pool['go_url'] = $goUrl;
|
||||
$pool['install_code'] = '<script src="' . $scriptUrl . '" defer></script>';
|
||||
$pool['trigger_code'] = '<a href="#" data-wecom-promotion="' . $key . '">添加企业微信</a>';
|
||||
}
|
||||
unset($pool);
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$todayClicks = 0;
|
||||
$onlineLinks = 0;
|
||||
foreach ($links as $link) {
|
||||
if ((int) ($link['status'] ?? 0) === 1) {
|
||||
$onlineLinks++;
|
||||
}
|
||||
if ((string) ($link['today_date'] ?? '') === $today) {
|
||||
$todayClicks += (int) ($link['today_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$config = QywxPromotionOpenWorkService::configurationStatus();
|
||||
$config['provider_callback_url'] = $domain . '/api/qywx-promotion/provider/callback';
|
||||
$config['auth_callback_url'] = QywxPromotionOpenWorkService::configuredRedirectUri(
|
||||
$domain . '/api/qywx-promotion/auth/callback'
|
||||
);
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
|
||||
'can_authorize' => self::canAuthorize($adminId, $adminInfo),
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
'config' => $config,
|
||||
'summary' => [
|
||||
'authorized_accounts' => count(array_filter($accounts, static fn (array $row): bool => (int) ($row['auth_status'] ?? 0) === 1)),
|
||||
'pool_count' => count($pools),
|
||||
'online_links' => $onlineLinks,
|
||||
'today_clicks' => $todayClicks,
|
||||
],
|
||||
'accounts' => $accounts,
|
||||
'pools' => $pools,
|
||||
'links' => $links,
|
||||
'allowed_link_hosts' => array_values((array) config('qywx_promotion.allowed_link_hosts', [])),
|
||||
];
|
||||
}
|
||||
|
||||
public static function authorizationUrl(int $adminId, array $adminInfo, string $domain): array
|
||||
{
|
||||
if (!self::canAuthorize($adminId, $adminInfo)) {
|
||||
throw new RuntimeException('只有系统管理员可以发起企业微信应用授权');
|
||||
}
|
||||
$redirectUri = rtrim($domain, '/') . '/api/qywx-promotion/auth/callback';
|
||||
|
||||
return ['url' => QywxPromotionOpenWorkService::authorizationUrl($adminId, $redirectUri)];
|
||||
}
|
||||
|
||||
public static function verifyAccount(int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
if (!self::canAuthorize($adminId, $adminInfo)) {
|
||||
throw new RuntimeException('只有系统管理员可以验证企业微信授权凭证');
|
||||
}
|
||||
self::assertAuthorizedAccount($id, false);
|
||||
|
||||
return QywxPromotionOpenWorkService::verifyAccount($id);
|
||||
}
|
||||
|
||||
public static function savePool(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 60) {
|
||||
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
|
||||
}
|
||||
$fallback = trim((string) ($params['fallback_url'] ?? ''));
|
||||
if (!QywxPromotionOpenWorkService::isAllowedPromotionUrl($fallback, true)) {
|
||||
throw new RuntimeException('兜底链接必须是已允许的 HTTPS 企业微信链接');
|
||||
}
|
||||
$now = time();
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
|
||||
'fallback_url' => $fallback,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($id > 0) {
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_pool')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data += [
|
||||
'public_key' => bin2hex(random_bytes(16)),
|
||||
'owner_admin_id' => $adminId,
|
||||
'dept_id' => self::primaryDeptId($adminId),
|
||||
'click_count' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($data);
|
||||
}
|
||||
|
||||
return ['id' => $id];
|
||||
}
|
||||
|
||||
public static function deletePool(int $id, int $adminId, array $adminInfo): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||||
$now = time();
|
||||
Db::transaction(function () use ($id, $now): void {
|
||||
Db::name('qywx_promotion_pool')->where('id', $id)->update(['delete_time' => $now, 'update_time' => $now]);
|
||||
Db::name('qywx_promotion_link')->where('pool_id', $id)->whereNull('delete_time')->update(['delete_time' => $now, 'update_time' => $now]);
|
||||
});
|
||||
}
|
||||
|
||||
public static function saveLink(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
|
||||
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 80) {
|
||||
throw new RuntimeException('请输入 1-80 个字符的推广链接名称');
|
||||
}
|
||||
$url = trim((string) ($params['wecom_url'] ?? ''));
|
||||
if (!QywxPromotionOpenWorkService::isAllowedPromotionUrl($url)) {
|
||||
throw new RuntimeException('推广链接必须是已允许的 HTTPS 企业微信链接');
|
||||
}
|
||||
$accountId = max(0, (int) ($params['account_id'] ?? 0));
|
||||
if ($accountId > 0) {
|
||||
self::assertAuthorizedAccount($accountId, true);
|
||||
}
|
||||
$startAt = self::parseTime($params['active_start'] ?? null);
|
||||
$endAt = self::parseTime($params['active_end'] ?? null);
|
||||
if ($startAt > 0 && $endAt > 0 && $startAt >= $endAt) {
|
||||
throw new RuntimeException('生效结束时间必须晚于开始时间');
|
||||
}
|
||||
$now = time();
|
||||
$data = [
|
||||
'pool_id' => $poolId,
|
||||
'account_id' => $accountId,
|
||||
'name' => $name,
|
||||
'group_name' => mb_substr(trim((string) ($params['group_name'] ?? '默认分组')), 0, 60),
|
||||
'wecom_url' => $url,
|
||||
'weight' => min(100, max(1, (int) ($params['weight'] ?? 1))),
|
||||
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
|
||||
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
|
||||
'active_start' => $startAt,
|
||||
'active_end' => $endAt,
|
||||
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($id > 0) {
|
||||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data += [
|
||||
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
|
||||
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
|
||||
'click_count' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => null,
|
||||
'last_click_time' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
|
||||
}
|
||||
|
||||
return ['id' => $id];
|
||||
}
|
||||
|
||||
public static function toggleLink(int $id, int $status, int $adminId, array $adminInfo): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'status' => $status === 1 ? 1 : 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function deleteLink(int $id, int $adminId, array $adminInfo): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'delete_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function assertScopedRow(string $table, int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('数据不存在');
|
||||
}
|
||||
$query = Db::name($table)->where('id', $id)->whereNull('delete_time');
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds !== null) {
|
||||
if ($visibleIds === []) {
|
||||
throw new RuntimeException('无权访问该数据');
|
||||
}
|
||||
$query->whereIn('owner_admin_id', $visibleIds);
|
||||
}
|
||||
$row = $query->find();
|
||||
if (!$row) {
|
||||
throw new RuntimeException('数据不存在或超出当前权限范围');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function assertAuthorizedAccount(int $id, bool $requireActive): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('授权企业不存在');
|
||||
}
|
||||
$query = Db::name('qywx_promotion_account')->where('id', $id)->whereNull('delete_time');
|
||||
if ($requireActive) {
|
||||
$query->where('auth_status', 1);
|
||||
}
|
||||
$row = $query->find();
|
||||
if (!$row) {
|
||||
throw new RuntimeException($requireActive ? '授权企业无效或已取消授权' : '授权企业不存在');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function applyOwnerScope($query, string $alias, ?array $visibleIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$query->whereIn($alias . '.owner_admin_id', $visibleIds);
|
||||
}
|
||||
|
||||
private static function canAuthorize(int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Db::name('admin_role')->alias('ar')
|
||||
->join('system_role r', 'r.id = ar.role_id AND r.delete_time IS NULL')
|
||||
->where('ar.admin_id', $adminId)
|
||||
->where('r.name', '管理员')
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
private static function primaryDeptId(int $adminId): int
|
||||
{
|
||||
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
|
||||
}
|
||||
|
||||
private static function parseTime(mixed $value): int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
$time = strtotime((string) $value);
|
||||
|
||||
return $time === false ? 0 : $time;
|
||||
}
|
||||
|
||||
private static function mask(string $value): string
|
||||
{
|
||||
$length = strlen($value);
|
||||
if ($length <= 8) {
|
||||
return $value === '' ? '' : str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ class ConversionLogic
|
||||
* @param array $params
|
||||
* @param int $adminId 当前操作 admin(来自 BaseAdminController)
|
||||
* @param array $adminInfo 当前 admin 完整信息(含 root / role_id 数组等)
|
||||
* @param int[]|null $trustedVisibleAdminIdsOverride 仅供服务端内部可信调用覆盖本次可见管理员;不从 HTTP 参数读取
|
||||
* @param int[]|null $trustedCostAllocationAdminIdsOverride 仅用于成本按加粉占比分摊的分母,不会放大任何业务指标
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* 数据权限:通过 DataScopeService::getVisibleAdminIds 拿到当前用户的"可见 admin id 集合"。
|
||||
@@ -29,20 +31,46 @@ class ConversionLogic
|
||||
* - []:可见为空(SCOPE_SELF 且无绑定且关闭 fallback),返回空数据
|
||||
* - 其他:用 visibleAdminIds 收窄 entities 加载、hydrate 数据归属、虚拟桶可见性、filters 选项
|
||||
*/
|
||||
public static function overview(array $params = [], int $adminId = 0, ?array $adminInfo = null): array
|
||||
public static function overview(
|
||||
array $params = [],
|
||||
int $adminId = 0,
|
||||
?array $adminInfo = null,
|
||||
?array $trustedVisibleAdminIdsOverride = null,
|
||||
?array $trustedCostAllocationAdminIdsOverride = null
|
||||
): array
|
||||
{
|
||||
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
|
||||
// 仅供需要“有效挂号”口径的内部看板调用;默认保持转换统计历史口径不变。
|
||||
$excludeCancelledAppointments = (int)($params['exclude_cancelled_appointments'] ?? 0) === 1;
|
||||
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
|
||||
$mediaChannelCode = MediaChannelService::normalizeStatsCode((string) ($params['media_channel_code'] ?? ''));
|
||||
$mediaChannel = $mediaChannelCode !== '' ? MediaChannelService::getChannelByCode($mediaChannelCode) : null;
|
||||
$filterEmptyEntities = $mediaChannel !== null;
|
||||
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
|
||||
$pageNo = max(1, (int)($params['page_no'] ?? 1));
|
||||
$pageSize = max(1, min(100, (int)($params['page_size'] ?? 15)));
|
||||
if ($trustedVisibleAdminIdsOverride !== null) {
|
||||
$trustedVisibleAdminIdsOverride = array_values(array_unique(array_filter(
|
||||
array_map('intval', $trustedVisibleAdminIdsOverride),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
if ($trustedCostAllocationAdminIdsOverride !== null) {
|
||||
$trustedCostAllocationAdminIdsOverride = array_values(array_unique(array_filter(
|
||||
array_map('intval', $trustedCostAllocationAdminIdsOverride),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
$pageSizeLimit = $trustedVisibleAdminIdsOverride !== null
|
||||
? max(100, count($trustedVisibleAdminIdsOverride))
|
||||
: 100;
|
||||
$pageSize = max(1, min($pageSizeLimit, (int)($params['page_size'] ?? 15)));
|
||||
|
||||
$visibleAdminIds = ($adminInfo !== null && $adminId > 0)
|
||||
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
|
||||
: null;
|
||||
$visibleAdminIds = $trustedVisibleAdminIdsOverride;
|
||||
if ($trustedVisibleAdminIdsOverride === null) {
|
||||
$visibleAdminIds = ($adminInfo !== null && $adminId > 0)
|
||||
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
|
||||
: null;
|
||||
}
|
||||
// 严格隔离:可见 admin 集合为空时直接返回空骨架,避免下游误以为是"全部"。
|
||||
if ($visibleAdminIds === []) {
|
||||
$emptyResult = [
|
||||
@@ -98,8 +126,36 @@ class ConversionLogic
|
||||
$adminToDeptIds = self::loadAdminDeptMap();
|
||||
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
|
||||
$allocationEntities = $entities;
|
||||
$allocationEntityIds = $entityIds;
|
||||
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
|
||||
if ($trustedCostAllocationAdminIdsOverride !== null) {
|
||||
// 个人口径下,仍以同部门全员加粉作为成本分摊分母,避免将整个部门成本全部计到一个人。
|
||||
$allocationEntities = self::loadEntities($dimension, $params, $trustedCostAllocationAdminIdsOverride);
|
||||
// 分摊只能使用实际响应中已允许的部门,防止同事的多部门绑定扩大成本范围。
|
||||
$allocationEntities = array_intersect_key($allocationEntities, $entities);
|
||||
$allocationEntityIds = array_keys($allocationEntities);
|
||||
self::hydrateFanStats(
|
||||
$allocationEntities,
|
||||
$dimension,
|
||||
$allocationEntityIds,
|
||||
$adminToDeptIds,
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
$mediaChannel,
|
||||
$trustedCostAllocationAdminIdsOverride
|
||||
);
|
||||
}
|
||||
self::hydrateAppointmentStats(
|
||||
$entities,
|
||||
$dimension,
|
||||
$entityIds,
|
||||
$adminToDeptIds,
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
$startDate,
|
||||
$endDate,
|
||||
$mediaChannel,
|
||||
$visibleAdminIds,
|
||||
$excludeCancelledAppointments
|
||||
);
|
||||
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
|
||||
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
|
||||
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
|
||||
@@ -871,7 +927,8 @@ class ConversionLogic
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?array $mediaChannel,
|
||||
?array $visibleAdminIds = null
|
||||
?array $visibleAdminIds = null,
|
||||
bool $excludeCancelledAppointments = false
|
||||
): void {
|
||||
$sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id';
|
||||
$query = Db::name('doctor_appointment')
|
||||
@@ -882,6 +939,10 @@ class ConversionLogic
|
||||
->fieldRaw("{$sourceExpr} AS source_admin_id, a.patient_id AS diagnosis_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
|
||||
->group("{$sourceExpr}, a.patient_id");
|
||||
|
||||
if ($excludeCancelledAppointments) {
|
||||
$query->where('a.status', '<>', 2);
|
||||
}
|
||||
|
||||
$query->where(static function (Query $subQuery): void {
|
||||
$subQuery->whereNull('u.id')
|
||||
->whereOr(static function (Query $orQuery): void {
|
||||
|
||||
@@ -39,7 +39,13 @@ class DoctorDailyStatsLogic
|
||||
*
|
||||
* @return array{start_date:string,end_date:string,rows:array,total:array<string,mixed>}
|
||||
*/
|
||||
public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
|
||||
public static function overview(
|
||||
array $params,
|
||||
int $viewerAdminId = 0,
|
||||
array $viewerAdminInfo = [],
|
||||
?array $trustedDoctorIds = null,
|
||||
?array $trustedAssistantIds = null
|
||||
): array
|
||||
{
|
||||
// dept_ids 透传至共享上下文:未传时仍按默认「中心」树解析(仅用于挂号率默认 0 等兜底);
|
||||
// 显式传入时由下方 $deptScopedAdminIds 分支用 adminToPrimary 取出医助集合,并下推到三类聚合作为「经手医助」筛选。
|
||||
@@ -57,7 +63,9 @@ class DoctorDailyStatsLogic
|
||||
$filterDoctorId = (int) ($params['doctor_id'] ?? 0);
|
||||
$deptFilterActive = self::hasExplicitDeptIds($params['dept_ids'] ?? null);
|
||||
|
||||
$doctorIds = self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo);
|
||||
$doctorIds = $trustedDoctorIds === null
|
||||
? self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo)
|
||||
: self::resolveTrustedDoctorIds($trustedDoctorIds);
|
||||
|
||||
if ($filterDoctorId > 0) {
|
||||
$doctorIds = in_array($filterDoctorId, $doctorIds, true) ? [$filterDoctorId] : [];
|
||||
@@ -65,8 +73,10 @@ class DoctorDailyStatsLogic
|
||||
|
||||
// 部门下医助集合(含全部子级展开后的 admin_dept 命中者);未显式选部门时不参与筛选 → null。
|
||||
// 显式选部门但集合为空 ⇒ 该部门下无可见医助,直接返回空结果。
|
||||
$deptScopedAdminIds = null;
|
||||
if ($deptFilterActive) {
|
||||
$deptScopedAdminIds = $trustedAssistantIds === null
|
||||
? null
|
||||
: self::normalizePositiveIds($trustedAssistantIds);
|
||||
if ($trustedAssistantIds === null && $deptFilterActive) {
|
||||
$deptScopedAdminIds = array_values(array_unique(array_map(
|
||||
'intval',
|
||||
array_keys($ctx['adminToPrimary'] ?? [])
|
||||
@@ -159,7 +169,7 @@ class DoctorDailyStatsLogic
|
||||
}
|
||||
|
||||
// 显式部门筛选时隐藏「该部门无任何关联」的医生,避免列出大量全 0 行。
|
||||
if ($deptFilterActive) {
|
||||
if ($deptFilterActive || $trustedAssistantIds !== null) {
|
||||
$rows = array_values(array_filter($rows, static function (array $r): bool {
|
||||
return (int) ($r['system_prescription_count'] ?? 0) > 0
|
||||
|| (int) ($r['manual_prescription_count'] ?? 0) > 0
|
||||
@@ -243,6 +253,37 @@ class DoctorDailyStatsLogic
|
||||
return $doctorIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅供服务端内部聚合页传入已经过权限计算的医生集合;仍再次校验医生角色与软删除状态。
|
||||
* HTTP 参数不会进入此分支。
|
||||
*
|
||||
* @param array<int|string,mixed> $trustedDoctorIds
|
||||
* @return int[]
|
||||
*/
|
||||
private static function resolveTrustedDoctorIds(array $trustedDoctorIds): array
|
||||
{
|
||||
$ids = self::normalizePositiveIds($trustedDoctorIds);
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::normalizePositiveIds(Db::name('admin_role')->alias('ar')
|
||||
->join('admin a', 'a.id = ar.admin_id')
|
||||
->where('ar.role_id', 1)
|
||||
->whereIn('ar.admin_id', $ids)
|
||||
->whereNull('a.delete_time')
|
||||
->column('ar.admin_id'));
|
||||
}
|
||||
|
||||
/** @param array<int|string,mixed> $ids @return int[] */
|
||||
private static function normalizePositiveIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
$ids
|
||||
), static fn (int $id): bool => $id > 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, float|int|null>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
<?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;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 数据驾驶舱聚合逻辑。
|
||||
*
|
||||
* 数据口径:
|
||||
* - 所有“业绩/接诊诊单”与业绩统计、业务订单列表保持一致:按业务订单创建时间,
|
||||
* 排除履约已取消/拒收/退款(4/9/10),金额取业务订单 amount,归属人取订单 creator_id。
|
||||
* - 今日加粉、挂号、面诊和转化率继续沿用 ConversionLogic;它们不是业绩指标。
|
||||
* - 趋势使用同一业绩条件的轻量按日 SQL,固定补齐最近 7 个自然日。
|
||||
* - 所有查询都使用 DataScopeService 返回的可见管理员集合收窄。
|
||||
*/
|
||||
class PerformanceDashboardLogic
|
||||
{
|
||||
private const TREND_DAYS = 7;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function overview(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
||||
$dayBeforeYesterday = date('Y-m-d', strtotime('-2 days'));
|
||||
$monthStart = date('Y-m-01');
|
||||
$previousMonthStart = date('Y-m-01', strtotime('first day of previous month'));
|
||||
$previousMonthLastDay = (int) date('t', strtotime($previousMonthStart));
|
||||
$comparisonDay = min((int) date('j'), $previousMonthLastDay);
|
||||
$previousMonthComparableEnd = date(
|
||||
'Y-m-d',
|
||||
strtotime($previousMonthStart . ' +' . max(0, $comparisonDay - 1) . ' days')
|
||||
);
|
||||
$trendStart = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
|
||||
|
||||
$scope = self::buildScopeContext($adminId, $adminInfo);
|
||||
/** @var array<int>|null $visibleAdminIds */
|
||||
$visibleAdminIds = $scope['_visible_admin_ids'];
|
||||
unset($scope['_visible_admin_ids']);
|
||||
|
||||
$orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $visibleAdminIds);
|
||||
$personalOrderDaily = self::loadPerformanceOrderDaily($monthStart, $today, [$adminId]);
|
||||
|
||||
$monthAmount = self::sumDailyMetric($orderDaily, $monthStart, $today, 'amount');
|
||||
$previousMonthAmount = self::sumDailyMetric(
|
||||
$orderDaily,
|
||||
$previousMonthStart,
|
||||
$previousMonthComparableEnd,
|
||||
'amount'
|
||||
);
|
||||
$yesterdayAmount = self::dailyMetric($orderDaily, $yesterday, 'amount');
|
||||
$dayBeforeAmount = self::dailyMetric($orderDaily, $dayBeforeYesterday, 'amount');
|
||||
$personalMonthAmount = self::sumDailyMetric($personalOrderDaily, $monthStart, $today, 'amount');
|
||||
|
||||
$todayOverview = ConversionLogic::overview([
|
||||
'dimension' => 'dept',
|
||||
'time_type' => 'today',
|
||||
'include_members' => 0,
|
||||
'include_filters' => 0,
|
||||
'page_no' => 1,
|
||||
'page_size' => 100,
|
||||
], $adminId, $adminInfo);
|
||||
$todaySummary = is_array($todayOverview['summary'] ?? null) ? $todayOverview['summary'] : [];
|
||||
$yesterdayOverview = ConversionLogic::overview([
|
||||
'dimension' => 'dept',
|
||||
'time_type' => 'yesterday',
|
||||
'include_members' => 0,
|
||||
'include_filters' => 0,
|
||||
'page_no' => 1,
|
||||
'page_size' => 100,
|
||||
], $adminId, $adminInfo);
|
||||
$yesterdaySummary = is_array($yesterdayOverview['summary'] ?? null)
|
||||
? $yesterdayOverview['summary']
|
||||
: [];
|
||||
|
||||
// 业绩指标必须直接复用业绩页的权威聚合,不能使用 ConversionLogic 的“双审完成单”。
|
||||
$todayPerformanceOverview = YejiStatsLogic::overview([
|
||||
'start_date' => $today,
|
||||
'end_date' => $today,
|
||||
], $adminId, $adminInfo);
|
||||
$appointmentRanking = self::buildAppointmentRanking($adminId, $adminInfo, $scope);
|
||||
$performanceRanking = self::buildPerformanceRanking(
|
||||
is_array($todayPerformanceOverview['rows'] ?? null) ? $todayPerformanceOverview['rows'] : []
|
||||
);
|
||||
$trendContext = YejiStatsLogic::resolveSharedYejiFilterContext([
|
||||
'start_date' => $trendStart,
|
||||
'end_date' => $today,
|
||||
], $adminId, $adminInfo);
|
||||
$trend = self::buildTrend($trendStart, $today, $visibleAdminIds, $orderDaily, $trendContext);
|
||||
$todayTrendIndex = max(0, count($trend['dates'] ?? []) - 1);
|
||||
$yesterdayTrendIndex = max(0, $todayTrendIndex - 1);
|
||||
|
||||
$todayAddFansCount = (int) ($trend['leads'][$todayTrendIndex] ?? 0);
|
||||
$yesterdayAddFansCount = (int) ($trend['leads'][$yesterdayTrendIndex] ?? 0);
|
||||
$todayAppointmentCount = (int) ($trend['appointments'][$todayTrendIndex] ?? 0);
|
||||
$yesterdayAppointmentCount = (int) ($trend['appointments'][$yesterdayTrendIndex] ?? 0);
|
||||
$todayInterviewCount = (int) ($todaySummary['interview_count'] ?? 0);
|
||||
$yesterdayInterviewCount = (int) ($yesterdaySummary['interview_count'] ?? 0);
|
||||
$todayOrderCount = (int) self::dailyMetric($orderDaily, $today, 'count');
|
||||
$yesterdayOrderCount = (int) self::dailyMetric($orderDaily, $yesterday, 'count');
|
||||
$todayOrderAmount = self::dailyMetric($orderDaily, $today, 'amount');
|
||||
$yesterdayOrderAmount = self::dailyMetric($orderDaily, $yesterday, 'amount');
|
||||
$todayPaidAppointmentRate = round((float) ($todaySummary['paid_appointment_rate'] ?? 0), 2);
|
||||
$yesterdayPaidAppointmentRate = round((float) ($yesterdaySummary['paid_appointment_rate'] ?? 0), 2);
|
||||
$todayInterviewReceiveRate = round((float) ($todaySummary['interview_receive_rate'] ?? 0), 2);
|
||||
$yesterdayInterviewReceiveRate = round((float) ($yesterdaySummary['interview_receive_rate'] ?? 0), 2);
|
||||
|
||||
$target = self::buildTargetProgress(
|
||||
$adminId,
|
||||
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF),
|
||||
date('Y-m'),
|
||||
$monthAmount,
|
||||
$personalMonthAmount
|
||||
);
|
||||
|
||||
return [
|
||||
'scope' => $scope,
|
||||
'performance' => [
|
||||
'month_amount' => round($monthAmount, 2),
|
||||
'month_compare_rate' => self::relativeChange($monthAmount, $previousMonthAmount),
|
||||
'month_compare_label' => '较上月同期',
|
||||
'yesterday_amount' => round($yesterdayAmount, 2),
|
||||
'yesterday_compare_rate' => self::relativeChange($yesterdayAmount, $dayBeforeAmount),
|
||||
'yesterday_compare_label' => '较前一日',
|
||||
'personal_month_amount' => round($personalMonthAmount, 2),
|
||||
],
|
||||
'today' => [
|
||||
'add_fans_count' => $todayAddFansCount,
|
||||
'appointment_total_count' => $todayAppointmentCount,
|
||||
'interview_count' => $todayInterviewCount,
|
||||
// 保留原响应字段名以兼容已发布前端,数值含义已统一为“计入业绩的业务订单”。
|
||||
'completed_order_count' => $todayOrderCount,
|
||||
'completed_order_amount' => $todayOrderAmount,
|
||||
'paid_appointment_rate' => $todayPaidAppointmentRate,
|
||||
'interview_receive_rate' => $todayInterviewReceiveRate,
|
||||
'comparisons' => [
|
||||
'add_fans_count' => self::buildComparison($todayAddFansCount, $yesterdayAddFansCount),
|
||||
'appointment_total_count' => self::buildComparison(
|
||||
$todayAppointmentCount,
|
||||
$yesterdayAppointmentCount
|
||||
),
|
||||
'interview_count' => self::buildComparison($todayInterviewCount, $yesterdayInterviewCount),
|
||||
'completed_order_count' => self::buildComparison($todayOrderCount, $yesterdayOrderCount),
|
||||
'completed_order_amount' => self::buildComparison($todayOrderAmount, $yesterdayOrderAmount),
|
||||
'paid_appointment_rate' => self::buildComparison(
|
||||
$todayPaidAppointmentRate,
|
||||
$yesterdayPaidAppointmentRate
|
||||
),
|
||||
'interview_receive_rate' => self::buildComparison(
|
||||
$todayInterviewReceiveRate,
|
||||
$yesterdayInterviewReceiveRate
|
||||
),
|
||||
],
|
||||
],
|
||||
'rankings' => [
|
||||
'appointments' => $appointmentRanking,
|
||||
'performance' => [
|
||||
'title' => '今日部门业绩排行',
|
||||
'scope_label' => (string) ($scope['label'] ?? ''),
|
||||
'items' => $performanceRanking,
|
||||
],
|
||||
],
|
||||
'trend' => $trend,
|
||||
'target' => $target,
|
||||
'meta' => [
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'timezone' => date_default_timezone_get(),
|
||||
'commission_note' => '本人业绩按当前账号创建的业务订单统计,排除已取消、拒收和退款订单。',
|
||||
'rate_note' => '近 7 天趋势与业绩统计一致:挂号排除已取消记录,进线仅统计当前范围内可归属业绩中心的新增客户事件,诊单按订单创建时间统计并排除履约 4/9/10。',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildScopeContext(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
|
||||
$roleIds = array_values(array_unique(array_filter(array_map('intval', $roleIds), static fn (int $id): bool => $id > 0)));
|
||||
|
||||
$roleNames = [];
|
||||
if ($roleIds !== []) {
|
||||
$roleNames = SystemRole::whereIn('id', $roleIds)
|
||||
->whereNull('delete_time')
|
||||
->order('sort', 'desc')
|
||||
->column('name');
|
||||
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
|
||||
}
|
||||
|
||||
$deptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
|
||||
$deptIds = array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $id): bool => $id > 0)));
|
||||
$deptNames = [];
|
||||
if ($deptIds !== []) {
|
||||
$deptNames = Dept::whereIn('id', $deptIds)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->column('name');
|
||||
$deptNames = array_values(array_filter(array_map('strval', $deptNames)));
|
||||
}
|
||||
|
||||
$isRoot = (int) ($adminInfo['root'] ?? 0) === 1;
|
||||
$scopeKey = $isRoot ? 'root' : [
|
||||
DataScopeService::SCOPE_ALL => 'all',
|
||||
DataScopeService::SCOPE_DEPT_AND_CHILD => 'dept_children',
|
||||
DataScopeService::SCOPE_DEPT => 'dept',
|
||||
DataScopeService::SCOPE_SELF => 'self',
|
||||
][$scopeValue] ?? 'self';
|
||||
$scopeLabel = $isRoot ? '全部数据' : DataScopeService::scopeLabel($scopeValue);
|
||||
|
||||
if ($visibleAdminIds === null) {
|
||||
$visibleMemberCount = (int) Db::name('admin')->whereNull('delete_time')->count();
|
||||
} else {
|
||||
$visibleMemberCount = count($visibleAdminIds);
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $scopeKey,
|
||||
'scope_value' => $scopeValue,
|
||||
'label' => $scopeLabel,
|
||||
'is_limited' => $visibleAdminIds !== null,
|
||||
'viewer_name' => (string) ($adminInfo['name'] ?? $adminInfo['account'] ?? ''),
|
||||
'role_ids' => $roleIds,
|
||||
'role_names' => $roleNames,
|
||||
'department_names' => $deptNames,
|
||||
'visible_member_count' => $visibleMemberCount,
|
||||
'_visible_admin_ids' => $visibleAdminIds,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $visibleAdminIds
|
||||
* @return array<string, array{amount: float, count: int}>
|
||||
*/
|
||||
private static function loadPerformanceOrderDaily(string $startDate, string $endDate, ?array $visibleAdminIds): array
|
||||
{
|
||||
if ($visibleAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$startTs = (int) strtotime($startDate . ' 00:00:00');
|
||||
$endTs = (int) strtotime($endDate . ' 23:59:59');
|
||||
$query = Db::name('tcm_prescription_order')
|
||||
->alias('po')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.create_time', 'between', [$startTs, $endTs]);
|
||||
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
|
||||
|
||||
if ($visibleAdminIds !== null) {
|
||||
$query->whereIn('po.creator_id', $visibleAdminIds);
|
||||
}
|
||||
|
||||
$rows = $query
|
||||
->fieldRaw("FROM_UNIXTIME(po.create_time, '%Y-%m-%d') AS date_label, SUM(po.amount) AS amount_sum, COUNT(*) AS order_count")
|
||||
->group('date_label')
|
||||
->order('date_label', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($date === '') {
|
||||
continue;
|
||||
}
|
||||
$out[$date] = [
|
||||
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
|
||||
'count' => (int) ($row['order_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{amount: float, count: int}> $daily
|
||||
*/
|
||||
private static function sumDailyMetric(array $daily, string $startDate, string $endDate, string $metric): float
|
||||
{
|
||||
$sum = 0.0;
|
||||
foreach ($daily as $date => $values) {
|
||||
if ($date < $startDate || $date > $endDate) {
|
||||
continue;
|
||||
}
|
||||
$sum += (float) ($values[$metric] ?? 0);
|
||||
}
|
||||
|
||||
return round($sum, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{amount: float, count: int}> $daily
|
||||
*/
|
||||
private static function dailyMetric(array $daily, string $date, string $metric): float
|
||||
{
|
||||
return round((float) ($daily[$date][$metric] ?? 0), 2);
|
||||
}
|
||||
|
||||
private static function relativeChange(float $current, float $previous): ?float
|
||||
{
|
||||
if (abs($previous) < 0.00001) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round((($current - $previous) / $previous) * 100, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{direction: string, rate: float|null, previous: float}
|
||||
*/
|
||||
private static function buildComparison(float $current, float $previous): array
|
||||
{
|
||||
$difference = $current - $previous;
|
||||
$direction = abs($difference) < 0.00001
|
||||
? 'flat'
|
||||
: ($difference > 0 ? 'up' : 'down');
|
||||
|
||||
return [
|
||||
'direction' => $direction,
|
||||
'rate' => self::relativeChange($current, $previous),
|
||||
'previous' => round($previous, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scope
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildAppointmentRanking(int $adminId, array $adminInfo, array $scope): array
|
||||
{
|
||||
$roleIds = array_map('intval', $scope['role_ids'] ?? []);
|
||||
$isDoctorSelf = ($scope['key'] ?? '') === 'self'
|
||||
&& in_array(1, $roleIds, true)
|
||||
&& !in_array(2, $roleIds, true);
|
||||
|
||||
if ($isDoctorSelf) {
|
||||
$doctorStats = DoctorDailyStatsLogic::overview([
|
||||
'start_date' => date('Y-m-d'),
|
||||
'end_date' => date('Y-m-d'),
|
||||
], $adminId, $adminInfo);
|
||||
$items = [];
|
||||
foreach (array_slice($doctorStats['rows'] ?? [], 0, 5) as $row) {
|
||||
$items[] = [
|
||||
'id' => (int) ($row['admin_id'] ?? 0),
|
||||
'name' => (string) ($row['doctor_name'] ?? ''),
|
||||
// DoctorDailyStats 的 total 含已取消;驾驶舱实时排行只统计有效挂号。
|
||||
'count' => max(
|
||||
0,
|
||||
(int) ($row['appointment_total'] ?? 0) - (int) ($row['appointment_cancelled'] ?? 0)
|
||||
),
|
||||
'amount' => round((float) ($row['deal_amount'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => '实时挂号排行',
|
||||
'kind' => 'doctor',
|
||||
'scope_label' => (string) ($scope['label'] ?? ''),
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
$rankingVisibleAdminIds = null;
|
||||
$rankingScopeLabel = (string) ($scope['label'] ?? '');
|
||||
if (
|
||||
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF) === DataScopeService::SCOPE_SELF
|
||||
&& in_array(2, $roleIds, true)
|
||||
) {
|
||||
$departmentAssistantIds = self::directDepartmentAssistantIds($adminId);
|
||||
if ($departmentAssistantIds !== []) {
|
||||
$rankingVisibleAdminIds = $departmentAssistantIds;
|
||||
$rankingScopeLabel = '本人所属部门';
|
||||
}
|
||||
}
|
||||
|
||||
$assistantStats = ConversionLogic::overview([
|
||||
'dimension' => 'assistant',
|
||||
'time_type' => 'today',
|
||||
'include_filters' => 0,
|
||||
'exclude_cancelled_appointments' => 1,
|
||||
'page_no' => 1,
|
||||
'page_size' => $rankingVisibleAdminIds !== null ? max(1, count($rankingVisibleAdminIds)) : 100,
|
||||
], $adminId, $adminInfo, $rankingVisibleAdminIds);
|
||||
$rows = is_array($assistantStats['lists'] ?? null) ? $assistantStats['lists'] : [];
|
||||
usort($rows, static function (array $a, array $b): int {
|
||||
$byAppointment = (int) ($b['appointment_total_count'] ?? 0) <=> (int) ($a['appointment_total_count'] ?? 0);
|
||||
if ($byAppointment !== 0) {
|
||||
return $byAppointment;
|
||||
}
|
||||
|
||||
$byAmount = (float) ($b['completed_order_amount'] ?? 0) <=> (float) ($a['completed_order_amount'] ?? 0);
|
||||
if ($byAmount !== 0) {
|
||||
return $byAmount;
|
||||
}
|
||||
|
||||
return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0);
|
||||
});
|
||||
|
||||
$items = [];
|
||||
foreach (array_slice($rows, 0, 5) as $row) {
|
||||
$items[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'count' => (int) ($row['appointment_total_count'] ?? 0),
|
||||
'amount' => round((float) ($row['completed_order_amount'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => '实时挂号排行',
|
||||
'kind' => 'assistant',
|
||||
'scope_label' => $rankingScopeLabel,
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* SELF 医助排行的卡片级例外:只扩展到当前账号所有有效直接部门内的有效医助。
|
||||
* 不展开子部门,也不改变驾驶舱其它指标的数据范围。
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private static function directDepartmentAssistantIds(int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$activeAdmin = Db::name('admin')
|
||||
->where('id', $adminId)
|
||||
->whereNull('delete_time')
|
||||
->value('id');
|
||||
if ((int) $activeAdmin <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$deptIds = Db::name('admin_dept')
|
||||
->alias('ad')
|
||||
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
|
||||
->where('ad.admin_id', $adminId)
|
||||
->column('ad.dept_id');
|
||||
$deptIds = array_values(array_unique(array_filter(
|
||||
array_map('intval', $deptIds),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
if ($deptIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$assistantIds = Db::name('admin_dept')
|
||||
->alias('ad')
|
||||
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
|
||||
->join('admin a', 'a.id = ad.admin_id AND a.delete_time IS NULL', 'INNER')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id AND ar.role_id = 2', 'INNER')
|
||||
->join('system_role sr', 'sr.id = ar.role_id AND sr.delete_time IS NULL', 'INNER')
|
||||
->whereIn('ad.dept_id', $deptIds)
|
||||
->distinct(true)
|
||||
->column('a.id');
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
array_map('intval', $assistantIds),
|
||||
static fn (int $id): bool => $id > 0
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function buildPerformanceRanking(array $rows): array
|
||||
{
|
||||
usort($rows, static function (array $a, array $b): int {
|
||||
$byAmount = (float) ($b['performance_amount'] ?? 0) <=> (float) ($a['performance_amount'] ?? 0);
|
||||
if ($byAmount !== 0) {
|
||||
return $byAmount;
|
||||
}
|
||||
|
||||
$byCount = (int) ($b['deal_order_count'] ?? 0) <=> (int) ($a['deal_order_count'] ?? 0);
|
||||
if ($byCount !== 0) {
|
||||
return $byCount;
|
||||
}
|
||||
|
||||
return (int) ($a['dept_id'] ?? 0) <=> (int) ($b['dept_id'] ?? 0);
|
||||
});
|
||||
|
||||
$items = [];
|
||||
foreach (array_slice($rows, 0, 5) as $row) {
|
||||
$items[] = [
|
||||
'id' => (int) ($row['dept_id'] ?? 0),
|
||||
'name' => (string) ($row['dept_name'] ?? '未归属中心'),
|
||||
'amount' => round((float) ($row['performance_amount'] ?? 0), 2),
|
||||
'count' => (int) ($row['deal_order_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $visibleAdminIds
|
||||
* @param array<string, array{amount: float, count: int}> $orderDaily
|
||||
* @param array<string, mixed> $trendContext
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildTrend(
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?array $visibleAdminIds,
|
||||
array $orderDaily,
|
||||
array $trendContext
|
||||
): array {
|
||||
$adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null)
|
||||
? $trendContext['adminToPrimary']
|
||||
: [];
|
||||
$tableRowDeptIds = is_array($trendContext['tableRowDeptIds'] ?? null)
|
||||
? array_values(array_map('intval', $trendContext['tableRowDeptIds']))
|
||||
: [];
|
||||
$leadDaily = self::loadLeadDaily($startDate, $endDate, $adminToPrimary, $tableRowDeptIds);
|
||||
$appointmentDaily = self::loadAppointmentDaily(
|
||||
$startDate,
|
||||
$endDate,
|
||||
$visibleAdminIds,
|
||||
$adminToPrimary,
|
||||
$tableRowDeptIds
|
||||
);
|
||||
$dates = [];
|
||||
$appointments = [];
|
||||
$leads = [];
|
||||
$orders = [];
|
||||
|
||||
$cursor = strtotime($startDate);
|
||||
$end = strtotime($endDate);
|
||||
while ($cursor <= $end) {
|
||||
$date = date('Y-m-d', $cursor);
|
||||
$dates[] = date('m-d', $cursor);
|
||||
$appointments[] = (int) ($appointmentDaily[$date] ?? 0);
|
||||
$leads[] = (int) ($leadDaily[$date] ?? 0);
|
||||
$orders[] = (int) ($orderDaily[$date]['count'] ?? 0);
|
||||
$cursor = strtotime('+1 day', $cursor);
|
||||
}
|
||||
|
||||
return [
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'dates' => $dates,
|
||||
'appointments' => $appointments,
|
||||
'leads' => $leads,
|
||||
'orders' => $orders,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 YejiStatsLogic 的“进线”一致:只有能映射到当前业绩中心展示行的管理员事件才计入。
|
||||
*
|
||||
* @param array<int, int> $adminToPrimary
|
||||
* @param int[] $tableRowDeptIds
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private static function loadLeadDaily(
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
array $adminToPrimary,
|
||||
array $tableRowDeptIds
|
||||
): array
|
||||
{
|
||||
$rowFlip = array_flip($tableRowDeptIds);
|
||||
$mappedAdminIds = [];
|
||||
foreach ($adminToPrimary as $adminId => $deptId) {
|
||||
$adminId = (int) $adminId;
|
||||
$deptId = (int) $deptId;
|
||||
if ($adminId > 0 && isset($rowFlip[$deptId])) {
|
||||
$mappedAdminIds[] = $adminId;
|
||||
}
|
||||
}
|
||||
$mappedAdminIds = array_values(array_unique($mappedAdminIds));
|
||||
if ($mappedAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->join('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL', 'INNER')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->whereIn('a.id', $mappedAdminIds)
|
||||
->where('e.event_time', 'between', [
|
||||
strtotime($startDate . ' 00:00:00'),
|
||||
strtotime($endDate . ' 23:59:59'),
|
||||
]);
|
||||
|
||||
$rows = $query
|
||||
->fieldRaw("FROM_UNIXTIME(e.event_time, '%Y-%m-%d') AS date_label, COUNT(*) AS item_count")
|
||||
->group('date_label')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($date !== '') {
|
||||
$out[$date] = (int) ($row['item_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $visibleAdminIds
|
||||
* 与 YejiStatsLogic 的“预约诊单”一致:appointment_date,状态 1/3/4,
|
||||
* 归属优先挂号医助、再诊单医助,缺失时回退医生;受限账号只保留当前业绩中心展示行。
|
||||
*
|
||||
* @param array<int>|null $visibleAdminIds
|
||||
* @param array<int, int> $adminToPrimary
|
||||
* @param int[] $tableRowDeptIds
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private static function loadAppointmentDaily(
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?array $visibleAdminIds,
|
||||
array $adminToPrimary,
|
||||
array $tableRowDeptIds
|
||||
): array {
|
||||
if ($visibleAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$effectiveAssistantSql = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
|
||||
$query = Db::name('doctor_appointment')
|
||||
->alias('a')
|
||||
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||||
->where('a.appointment_date', 'between', [$startDate, $endDate])
|
||||
->whereIn('a.status', [1, 3, 4])
|
||||
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
|
||||
|
||||
$rows = $query
|
||||
->field([
|
||||
'a.appointment_date AS date_label',
|
||||
Db::raw("({$effectiveAssistantSql}) AS effective_assistant_id"),
|
||||
'a.doctor_id',
|
||||
Db::raw('COUNT(*) AS appointment_count'),
|
||||
])
|
||||
->group(['a.appointment_date', $effectiveAssistantSql, 'a.doctor_id'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$visibleFlip = $visibleAdminIds !== null ? array_flip($visibleAdminIds) : null;
|
||||
$rowFlip = array_flip($tableRowDeptIds);
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($date === '') {
|
||||
continue;
|
||||
}
|
||||
$effectiveAssistantId = (int) ($row['effective_assistant_id'] ?? 0);
|
||||
$doctorId = (int) ($row['doctor_id'] ?? 0);
|
||||
if ($visibleFlip !== null) {
|
||||
if ($effectiveAssistantId > 0) {
|
||||
if (!isset($visibleFlip[$effectiveAssistantId])) {
|
||||
continue;
|
||||
}
|
||||
} elseif ($doctorId <= 0 || !isset($visibleFlip[$doctorId])) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$deptId = $effectiveAssistantId > 0
|
||||
? (int) ($adminToPrimary[$effectiveAssistantId] ?? 0)
|
||||
: 0;
|
||||
if ($deptId <= 0 && $doctorId > 0) {
|
||||
$deptId = (int) ($adminToPrimary[$doctorId] ?? 0);
|
||||
}
|
||||
if ($visibleFlip !== null && !isset($rowFlip[$deptId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[$date] = ($out[$date] ?? 0) + (int) ($row['appointment_count'] ?? 0);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildTargetProgress(
|
||||
int $adminId,
|
||||
int $scopeValue,
|
||||
string $yearMonth,
|
||||
float $completedAmount,
|
||||
float $personalAmount
|
||||
): array {
|
||||
$deptIds = self::targetDeptIds($adminId, $scopeValue);
|
||||
$query = Db::name('dept_performance_target')->where('year_month', $yearMonth);
|
||||
if ($deptIds !== null) {
|
||||
if ($deptIds === []) {
|
||||
return self::emptyTarget($yearMonth, $completedAmount, $personalAmount);
|
||||
}
|
||||
$query->whereIn('dept_id', $deptIds);
|
||||
}
|
||||
|
||||
$rows = $query->field('dept_id, dept_name, target_amount')->select()->toArray();
|
||||
$targetAmount = 0.0;
|
||||
foreach ($rows as $row) {
|
||||
$targetAmount += (float) ($row['target_amount'] ?? 0);
|
||||
}
|
||||
$targetAmount = round($targetAmount, 2);
|
||||
|
||||
return [
|
||||
'year_month' => $yearMonth,
|
||||
'target_amount' => $targetAmount,
|
||||
'completed_amount' => round($completedAmount, 2),
|
||||
'completion_rate' => $targetAmount > 0 ? round($completedAmount / $targetAmount * 100, 2) : null,
|
||||
'personal_amount' => round($personalAmount, 2),
|
||||
'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null,
|
||||
'department_count' => count($rows),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int>|null null 表示全部部门。
|
||||
*/
|
||||
private static function targetDeptIds(int $adminId, int $scopeValue): ?array
|
||||
{
|
||||
if ($scopeValue === DataScopeService::SCOPE_ALL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ownDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
|
||||
$ownDeptIds = array_values(array_unique(array_filter(array_map('intval', $ownDeptIds), static fn (int $id): bool => $id > 0)));
|
||||
if ($ownDeptIds === [] || $scopeValue !== DataScopeService::SCOPE_DEPT_AND_CHILD) {
|
||||
return $ownDeptIds;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($ownDeptIds as $deptId) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$out[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function emptyTarget(string $yearMonth, float $completedAmount, float $personalAmount): array
|
||||
{
|
||||
return [
|
||||
'year_month' => $yearMonth,
|
||||
'target_amount' => 0.0,
|
||||
'completed_amount' => round($completedAmount, 2),
|
||||
'completion_rate' => null,
|
||||
'personal_amount' => round($personalAmount, 2),
|
||||
'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null,
|
||||
'department_count' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\qywx\QywxPromotionOpenWorkService;
|
||||
use app\common\service\qywx\QywxPromotionRedirectService;
|
||||
use think\facade\Log;
|
||||
|
||||
/** 企业微信推广公开端点:服务商回调、授权回跳、JS 与随机跳转。 */
|
||||
class QywxPromotionPublicController extends BaseController
|
||||
{
|
||||
public function script(string $key)
|
||||
{
|
||||
if (!QywxPromotionRedirectService::poolExists($key)) {
|
||||
return response('/* promotion pool not found */', 404, ['Content-Type' => 'application/javascript; charset=utf-8']);
|
||||
}
|
||||
$goUrl = rtrim($this->request->domain(), '/') . '/api/qywx-promotion/go/' . $key;
|
||||
$jsonKey = json_encode($key, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$jsonGo = json_encode($goUrl, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$javascript = <<<JS
|
||||
(function(w,d){
|
||||
'use strict';
|
||||
var key={$jsonKey}, go={$jsonGo};
|
||||
function openPromotion(){
|
||||
var source=w.location.href;
|
||||
w.location.assign(go+'?from='+encodeURIComponent(source));
|
||||
}
|
||||
d.addEventListener('click',function(event){
|
||||
var node=event.target&&event.target.closest?event.target.closest('[data-wecom-promotion="'+key+'"],.wecom-promotion-link[data-pool="'+key+'"]'):null;
|
||||
if(!node){return;}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openPromotion();
|
||||
},true);
|
||||
w.WecomPromotion=w.WecomPromotion||{};
|
||||
w.WecomPromotion[key]={open:openPromotion};
|
||||
})(window,document);
|
||||
JS;
|
||||
|
||||
return response($javascript, 200, [
|
||||
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=60',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
public function redirect(string $key)
|
||||
{
|
||||
$picked = QywxPromotionRedirectService::pick($key, [
|
||||
'source_url' => (string) $this->request->get('from', ''),
|
||||
'referer' => (string) $this->request->header('referer', ''),
|
||||
'user_agent' => (string) $this->request->header('user-agent', ''),
|
||||
'ip' => (string) $this->request->ip(),
|
||||
]);
|
||||
if (!$picked) {
|
||||
return response('当前暂无可用的企业微信推广链接,请稍后再试。', 503, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect($picked['url'], 302)->header([
|
||||
'Cache-Control' => 'no-store',
|
||||
'Referrer-Policy' => 'no-referrer',
|
||||
]);
|
||||
}
|
||||
|
||||
public function providerCallback()
|
||||
{
|
||||
try {
|
||||
$psr = QywxPromotionOpenWorkService::serveProviderCallback();
|
||||
$body = $psr->getBody();
|
||||
$body->rewind();
|
||||
$headers = [];
|
||||
if ($psr->getHeaderLine('Content-Type') !== '') {
|
||||
$headers['Content-Type'] = $psr->getHeaderLine('Content-Type');
|
||||
}
|
||||
|
||||
return response($body->getContents(), $psr->getStatusCode(), $headers);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微推广服务商回调失败:' . $e->getMessage(), ['exception' => $e]);
|
||||
|
||||
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
}
|
||||
}
|
||||
|
||||
public function authCallback()
|
||||
{
|
||||
$fallback = rtrim($this->request->domain(), '/') . '/admin/first_visit/wecom_promotion';
|
||||
$returnUrl = QywxPromotionOpenWorkService::configuredAdminReturnUrl($fallback);
|
||||
try {
|
||||
$result = QywxPromotionOpenWorkService::consumeAuthorizationCallback(
|
||||
trim((string) $this->request->get('auth_code', '')),
|
||||
trim((string) $this->request->get('state', ''))
|
||||
);
|
||||
$query = ['wecom_auth' => 'success', 'account_id' => (int) $result['id']];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微推广授权回跳失败:' . $e->getMessage(), ['exception' => $e]);
|
||||
$query = ['wecom_auth' => 'failed', 'message' => mb_substr($e->getMessage(), 0, 160)];
|
||||
}
|
||||
|
||||
return redirect($returnUrl . (str_contains($returnUrl, '?') ? '&' : '?') . http_build_query($query), 302);
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,9 @@ use think\facade\Route;
|
||||
// 企业微信「客户联系」事件回调:GET 验签(echostr)、POST 收事件
|
||||
Route::rule('qywx/external-contact/notify', 'QywxExternalContactCallback/notify', 'GET|POST');
|
||||
Route::post('ej-pharmacy/webhook', 'EjPharmacyCallback/webhook');
|
||||
|
||||
// 企业微信推广助手:服务商应用指令、授权回跳、公开 JS 与随机分流。
|
||||
Route::rule('qywx-promotion/provider/callback', 'QywxPromotionPublic/providerCallback', 'GET|POST');
|
||||
Route::get('qywx-promotion/auth/callback', 'QywxPromotionPublic/authCallback');
|
||||
Route::get('qywx-promotion/js/:key', 'QywxPromotionPublic/script');
|
||||
Route::get('qywx-promotion/go/:key', 'QywxPromotionPublic/redirect');
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** 企业微信推广凭证加密器:密钥仅来自服务器配置,密文可安全落库。 */
|
||||
class QywxPromotionCredentialCipher
|
||||
{
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
public static function encrypt(string $plain): string
|
||||
{
|
||||
if ($plain === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt($plain, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($cipher === false) {
|
||||
throw new RuntimeException('企业微信授权凭证加密失败');
|
||||
}
|
||||
|
||||
return base64_encode(json_encode([
|
||||
'v' => 1,
|
||||
'iv' => base64_encode($iv),
|
||||
'tag' => base64_encode($tag),
|
||||
'data' => base64_encode($cipher),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public static function decrypt(string $payload): string
|
||||
{
|
||||
if ($payload === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$json = base64_decode($payload, true);
|
||||
$data = is_string($json) ? json_decode($json, true) : null;
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('企业微信授权凭证格式无效');
|
||||
}
|
||||
|
||||
$iv = base64_decode((string) ($data['iv'] ?? ''), true);
|
||||
$tag = base64_decode((string) ($data['tag'] ?? ''), true);
|
||||
$cipher = base64_decode((string) ($data['data'] ?? ''), true);
|
||||
if (!is_string($iv) || !is_string($tag) || !is_string($cipher)) {
|
||||
throw new RuntimeException('企业微信授权凭证格式无效');
|
||||
}
|
||||
|
||||
$plain = openssl_decrypt($cipher, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if ($plain === false) {
|
||||
throw new RuntimeException('企业微信授权凭证解密失败,请检查 CREDENTIAL_KEY 是否发生变更');
|
||||
}
|
||||
|
||||
return $plain;
|
||||
}
|
||||
|
||||
private static function key(): string
|
||||
{
|
||||
$material = trim((string) config('qywx_promotion.credential_key', ''));
|
||||
if ($material === '') {
|
||||
$material = trim((string) config('qywx_promotion.suite_secret', ''));
|
||||
}
|
||||
if ($material === '') {
|
||||
throw new RuntimeException('未配置企业微信推广凭证加密密钥');
|
||||
}
|
||||
|
||||
return hash('sha256', $material, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use EasyWeChat\OpenWork\Application;
|
||||
use EasyWeChat\OpenWork\Message;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/** 企业微信服务商授权流程及授权企业凭证管理。 */
|
||||
class QywxPromotionOpenWorkService
|
||||
{
|
||||
public static function configurationStatus(): array
|
||||
{
|
||||
$suiteId = self::configString('suite_id');
|
||||
$required = ['provider_corp_id', 'suite_id', 'suite_secret', 'token', 'aes_key'];
|
||||
$missing = [];
|
||||
foreach ($required as $key) {
|
||||
if (self::configString($key) === '') {
|
||||
$missing[] = $key;
|
||||
}
|
||||
}
|
||||
if (self::credentialMaterial() === '') {
|
||||
$missing[] = 'credential_key';
|
||||
}
|
||||
|
||||
$ticketAt = 0;
|
||||
if ($suiteId !== '' && self::tableExists('qywx_promotion_provider_state')) {
|
||||
$ticketAt = (int) (Db::name('qywx_promotion_provider_state')
|
||||
->where('suite_id', $suiteId)
|
||||
->value('ticket_received_at') ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => (bool) config('qywx_promotion.enabled', false),
|
||||
'configured' => $missing === [],
|
||||
'ready' => (bool) config('qywx_promotion.enabled', false) && $missing === [] && $ticketAt > 0,
|
||||
'missing' => $missing,
|
||||
'suite_id_masked' => self::mask($suiteId),
|
||||
'ticket_received_at' => $ticketAt,
|
||||
];
|
||||
}
|
||||
|
||||
public static function authorizationUrl(int $adminId, string $redirectUri): string
|
||||
{
|
||||
self::assertReady();
|
||||
$redirectUri = self::configuredRedirectUri($redirectUri);
|
||||
if ($redirectUri === '') {
|
||||
throw new RuntimeException('无法生成企业微信授权回调地址');
|
||||
}
|
||||
|
||||
$app = self::application();
|
||||
$suiteAccessToken = $app->getSuiteAccessToken()->getToken();
|
||||
$response = $app->getHttpClient()->request('GET', 'cgi-bin/service/get_pre_auth_code', [
|
||||
'query' => ['suite_access_token' => $suiteAccessToken],
|
||||
])->toArray(false);
|
||||
$preAuthCode = trim((string) ($response['pre_auth_code'] ?? ''));
|
||||
if ($preAuthCode === '') {
|
||||
throw new RuntimeException('获取企业微信预授权码失败:' . (string) ($response['errmsg'] ?? '未知错误'));
|
||||
}
|
||||
|
||||
return 'https://open.work.weixin.qq.com/3rdapp/install?' . http_build_query([
|
||||
'suite_id' => self::configString('suite_id'),
|
||||
'pre_auth_code' => $preAuthCode,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'state' => self::makeState($adminId),
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
public static function consumeAuthorizationCallback(string $authCode, string $state): array
|
||||
{
|
||||
$adminId = self::verifyState($state);
|
||||
if ($authCode === '') {
|
||||
throw new RuntimeException('企业微信未返回临时授权码');
|
||||
}
|
||||
|
||||
return self::exchangePermanentCode($authCode, $adminId);
|
||||
}
|
||||
|
||||
public static function exchangePermanentCode(string $authCode, int $adminId = 0): array
|
||||
{
|
||||
self::assertConfigured();
|
||||
$app = self::application();
|
||||
$suiteAccessToken = $app->getSuiteAccessToken()->getToken();
|
||||
$response = $app->getHttpClient()->request('POST', 'cgi-bin/service/get_permanent_code', [
|
||||
'query' => ['suite_access_token' => $suiteAccessToken],
|
||||
'json' => ['auth_code' => $authCode],
|
||||
])->toArray(false);
|
||||
$permanentCode = trim((string) ($response['permanent_code'] ?? ''));
|
||||
$corpInfo = is_array($response['auth_corp_info'] ?? null) ? $response['auth_corp_info'] : [];
|
||||
$corpId = trim((string) ($corpInfo['corpid'] ?? ''));
|
||||
if ($permanentCode === '' || $corpId === '') {
|
||||
throw new RuntimeException('换取企业永久授权码失败:' . (string) ($response['errmsg'] ?? '返回信息不完整'));
|
||||
}
|
||||
|
||||
return self::saveAuthorization($corpId, $permanentCode, $response, $adminId);
|
||||
}
|
||||
|
||||
public static function verifyAccount(int $accountId): array
|
||||
{
|
||||
self::assertConfigured();
|
||||
$row = Db::name('qywx_promotion_account')->where('id', $accountId)->whereNull('delete_time')->find();
|
||||
if (!$row) {
|
||||
throw new RuntimeException('授权企业不存在');
|
||||
}
|
||||
|
||||
$permanentCode = QywxPromotionCredentialCipher::decrypt((string) ($row['permanent_code_cipher'] ?? ''));
|
||||
$authorization = self::application()->getAuthorization((string) $row['corp_id'], $permanentCode)->toArray();
|
||||
self::saveAuthorization((string) $row['corp_id'], $permanentCode, $authorization, (int) ($row['owner_admin_id'] ?? 0));
|
||||
|
||||
return ['id' => $accountId, 'verified_at' => time()];
|
||||
}
|
||||
|
||||
public static function serveProviderCallback()
|
||||
{
|
||||
self::assertConfigured();
|
||||
$app = self::application();
|
||||
$server = $app->getServer();
|
||||
|
||||
$server->handleAuthCreated(function (Message $message, \Closure $next) {
|
||||
$authCode = trim((string) ($message['AuthCode'] ?? ''));
|
||||
if ($authCode !== '') {
|
||||
try {
|
||||
self::exchangePermanentCode($authCode, 0);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微推广 create_auth 处理失败:' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
});
|
||||
|
||||
$server->handleAuthChanged(function (Message $message, \Closure $next) {
|
||||
$corpId = trim((string) ($message['AuthCorpId'] ?? $message['AuthCorpID'] ?? ''));
|
||||
if ($corpId !== '') {
|
||||
try {
|
||||
self::refreshByCorpId($corpId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('企微推广 change_auth 处理失败:' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
});
|
||||
|
||||
$server->handleAuthCancelled(function (Message $message, \Closure $next) {
|
||||
$corpId = trim((string) ($message['AuthCorpId'] ?? $message['AuthCorpID'] ?? ''));
|
||||
if ($corpId !== '') {
|
||||
Db::name('qywx_promotion_account')->where('corp_id', $corpId)->whereNull('delete_time')->update([
|
||||
'auth_status' => 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $next($message);
|
||||
});
|
||||
|
||||
return $server->serve();
|
||||
}
|
||||
|
||||
public static function configuredRedirectUri(string $fallback): string
|
||||
{
|
||||
return self::configString('redirect_uri') ?: trim($fallback);
|
||||
}
|
||||
|
||||
public static function configuredAdminReturnUrl(string $fallback): string
|
||||
{
|
||||
return self::configString('admin_return_url') ?: trim($fallback);
|
||||
}
|
||||
|
||||
public static function isAllowedPromotionUrl(string $url, bool $allowEmpty = false): bool
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
return $allowEmpty;
|
||||
}
|
||||
$parts = parse_url($url);
|
||||
if (!is_array($parts) || strtolower((string) ($parts['scheme'] ?? '')) !== 'https') {
|
||||
return false;
|
||||
}
|
||||
$host = strtolower(trim((string) ($parts['host'] ?? '')));
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
foreach ((array) config('qywx_promotion.allowed_link_hosts', []) as $allowed) {
|
||||
$allowed = strtolower(trim((string) $allowed));
|
||||
if ($allowed !== '' && ($host === $allowed || str_ends_with($host, '.' . $allowed))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function application(): Application
|
||||
{
|
||||
$app = new Application([
|
||||
'corp_id' => self::configString('provider_corp_id'),
|
||||
'provider_secret' => '',
|
||||
'suite_id' => self::configString('suite_id'),
|
||||
'suite_secret' => self::configString('suite_secret'),
|
||||
'token' => self::configString('token'),
|
||||
'aes_key' => self::configString('aes_key'),
|
||||
]);
|
||||
$app->setSuiteTicket(new QywxPromotionSuiteTicket(self::configString('suite_id')));
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
private static function refreshByCorpId(string $corpId): void
|
||||
{
|
||||
$row = Db::name('qywx_promotion_account')->where('corp_id', $corpId)->whereNull('delete_time')->find();
|
||||
if (!$row) {
|
||||
return;
|
||||
}
|
||||
$permanentCode = QywxPromotionCredentialCipher::decrypt((string) $row['permanent_code_cipher']);
|
||||
$authorization = self::application()->getAuthorization($corpId, $permanentCode)->toArray();
|
||||
self::saveAuthorization($corpId, $permanentCode, $authorization, (int) ($row['owner_admin_id'] ?? 0));
|
||||
}
|
||||
|
||||
private static function saveAuthorization(string $corpId, string $permanentCode, array $response, int $adminId): array
|
||||
{
|
||||
$corpInfo = is_array($response['auth_corp_info'] ?? null) ? $response['auth_corp_info'] : [];
|
||||
$authInfo = is_array($response['auth_info'] ?? null) ? $response['auth_info'] : [];
|
||||
$agents = is_array($authInfo['agent'] ?? null) ? $authInfo['agent'] : [];
|
||||
$agent = is_array($agents[0] ?? null) ? $agents[0] : [];
|
||||
$now = time();
|
||||
$existing = Db::name('qywx_promotion_account')->where('corp_id', $corpId)->find();
|
||||
$ownerId = $adminId > 0 ? $adminId : (int) ($existing['owner_admin_id'] ?? 0);
|
||||
$deptId = $ownerId > 0 ? self::primaryDeptId($ownerId) : (int) ($existing['dept_id'] ?? 0);
|
||||
$data = [
|
||||
'corp_name' => trim((string) ($corpInfo['corp_name'] ?? $existing['corp_name'] ?? $corpId)),
|
||||
'permanent_code_cipher' => QywxPromotionCredentialCipher::encrypt($permanentCode),
|
||||
'agent_id' => trim((string) ($agent['agentid'] ?? $existing['agent_id'] ?? '')),
|
||||
// 授权响应可能包含 permanent_code;数据库元数据中只保留脱敏后的授权信息。
|
||||
'auth_info_json' => json_encode(self::sanitizeAuthInfo($response), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'auth_status' => 1,
|
||||
'owner_admin_id' => $ownerId,
|
||||
'dept_id' => $deptId,
|
||||
'authorized_at' => (int) ($existing['authorized_at'] ?? 0) ?: $now,
|
||||
'last_refresh_at' => $now,
|
||||
'update_time' => $now,
|
||||
'delete_time' => null,
|
||||
];
|
||||
if ($existing) {
|
||||
Db::name('qywx_promotion_account')->where('id', (int) $existing['id'])->update($data);
|
||||
$id = (int) $existing['id'];
|
||||
} else {
|
||||
$data['corp_id'] = $corpId;
|
||||
$data['create_time'] = $now;
|
||||
$id = (int) Db::name('qywx_promotion_account')->insertGetId($data);
|
||||
}
|
||||
|
||||
return ['id' => $id, 'corp_id' => $corpId, 'corp_name' => $data['corp_name']];
|
||||
}
|
||||
|
||||
private static function makeState(int $adminId): string
|
||||
{
|
||||
$payload = self::base64UrlEncode(json_encode([
|
||||
'a' => $adminId,
|
||||
't' => time(),
|
||||
'n' => bin2hex(random_bytes(8)),
|
||||
], JSON_THROW_ON_ERROR));
|
||||
$signature = self::base64UrlEncode(hash_hmac('sha256', $payload, self::stateKey(), true));
|
||||
|
||||
return $payload . '.' . $signature;
|
||||
}
|
||||
|
||||
private static function sanitizeAuthInfo(array $data): array
|
||||
{
|
||||
$sensitiveKeys = ['permanent_code', 'access_token', 'suite_ticket', 'suite_secret', 'provider_secret'];
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array(strtolower((string) $key), $sensitiveKeys, true)) {
|
||||
unset($data[$key]);
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$data[$key] = self::sanitizeAuthInfo($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function verifyState(string $state): int
|
||||
{
|
||||
$parts = explode('.', $state, 2);
|
||||
if (count($parts) !== 2) {
|
||||
throw new RuntimeException('企业微信授权 state 无效');
|
||||
}
|
||||
[$payload, $signature] = $parts;
|
||||
$expected = self::base64UrlEncode(hash_hmac('sha256', $payload, self::stateKey(), true));
|
||||
if (!hash_equals($expected, $signature)) {
|
||||
throw new RuntimeException('企业微信授权 state 验证失败');
|
||||
}
|
||||
$data = json_decode(self::base64UrlDecode($payload), true);
|
||||
if (!is_array($data) || time() - (int) ($data['t'] ?? 0) > 1800) {
|
||||
throw new RuntimeException('企业微信授权请求已过期,请重新发起');
|
||||
}
|
||||
|
||||
return max(0, (int) ($data['a'] ?? 0));
|
||||
}
|
||||
|
||||
private static function assertReady(): void
|
||||
{
|
||||
$status = self::configurationStatus();
|
||||
if (!$status['enabled']) {
|
||||
throw new RuntimeException('企业微信推广授权尚未启用');
|
||||
}
|
||||
self::assertConfigured();
|
||||
if (!$status['ticket_received_at']) {
|
||||
throw new RuntimeException('尚未收到 suite_ticket,请先配置企业微信应用指令回调');
|
||||
}
|
||||
}
|
||||
|
||||
private static function assertConfigured(): void
|
||||
{
|
||||
$status = self::configurationStatus();
|
||||
if (!$status['configured']) {
|
||||
throw new RuntimeException('企业微信服务商配置不完整:' . implode(', ', $status['missing']));
|
||||
}
|
||||
}
|
||||
|
||||
private static function stateKey(): string
|
||||
{
|
||||
$key = self::credentialMaterial();
|
||||
if ($key === '') {
|
||||
throw new RuntimeException('未配置企业微信推广授权签名密钥');
|
||||
}
|
||||
|
||||
return hash('sha256', 'qywx-promotion-state|' . $key);
|
||||
}
|
||||
|
||||
private static function credentialMaterial(): string
|
||||
{
|
||||
return self::configString('credential_key') ?: self::configString('suite_secret');
|
||||
}
|
||||
|
||||
private static function configString(string $key): string
|
||||
{
|
||||
return trim((string) config('qywx_promotion.' . $key, ''));
|
||||
}
|
||||
|
||||
private static function primaryDeptId(int $adminId): int
|
||||
{
|
||||
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
|
||||
}
|
||||
|
||||
private static function mask(string $value): string
|
||||
{
|
||||
$length = strlen($value);
|
||||
if ($length <= 8) {
|
||||
return $value === '' ? '' : str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
|
||||
}
|
||||
|
||||
private static function base64UrlEncode(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private static function base64UrlDecode(string $value): string
|
||||
{
|
||||
$value = strtr($value, '-_', '+/');
|
||||
$padding = strlen($value) % 4;
|
||||
if ($padding > 0) {
|
||||
$value .= str_repeat('=', 4 - $padding);
|
||||
}
|
||||
|
||||
return (string) base64_decode($value, true);
|
||||
}
|
||||
|
||||
private static function tableExists(string $table): bool
|
||||
{
|
||||
try {
|
||||
return Db::query("SHOW TABLES LIKE '" . config('database.connections.mysql.prefix', '') . $table . "'") !== [];
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/** 公开推广链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
|
||||
class QywxPromotionRedirectService
|
||||
{
|
||||
/** @return array{url:string,link_id:int}|null */
|
||||
public static function pick(string $publicKey, array $context = []): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $publicKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Db::transaction(function () use ($publicKey, $context): ?array {
|
||||
$pool = Db::name('qywx_promotion_pool')
|
||||
->where('public_key', $publicKey)
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$pool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$today = date('Y-m-d', $now);
|
||||
$links = Db::name('qywx_promotion_link')->alias('l')
|
||||
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
|
||||
->where('l.pool_id', (int) $pool['id'])
|
||||
->where('l.status', 1)
|
||||
->whereNull('l.delete_time')
|
||||
->whereRaw('(l.account_id = 0 OR a.auth_status = 1)')
|
||||
->whereRaw('(l.active_start = 0 OR l.active_start <= ' . $now . ')')
|
||||
->whereRaw('(l.active_end = 0 OR l.active_end >= ' . $now . ')')
|
||||
->whereRaw("(l.daily_limit = 0 OR l.today_date IS NULL OR l.today_date <> '" . addslashes($today) . "' OR l.today_count < l.daily_limit)")
|
||||
->field('l.*')
|
||||
->lock(true)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$selected = self::weightedRandom($links);
|
||||
if (!$selected) {
|
||||
$fallback = trim((string) ($pool['fallback_url'] ?? ''));
|
||||
if (QywxPromotionOpenWorkService::isAllowedPromotionUrl($fallback, true) && $fallback !== '') {
|
||||
return ['url' => $fallback, 'link_id' => 0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$sameDay = (string) ($selected['today_date'] ?? '') === $today;
|
||||
Db::name('qywx_promotion_link')->where('id', (int) $selected['id'])->update([
|
||||
'click_count' => (int) ($selected['click_count'] ?? 0) + 1,
|
||||
'today_count' => $sameDay ? (int) ($selected['today_count'] ?? 0) + 1 : 1,
|
||||
'today_date' => $today,
|
||||
'last_click_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::name('qywx_promotion_pool')->where('id', (int) $pool['id'])->inc('click_count')->update([
|
||||
'update_time' => $now,
|
||||
]);
|
||||
self::recordClick((int) $pool['id'], (int) $selected['id'], $context, $now);
|
||||
|
||||
return ['url' => (string) $selected['wecom_url'], 'link_id' => (int) $selected['id']];
|
||||
});
|
||||
}
|
||||
|
||||
public static function poolExists(string $publicKey): bool
|
||||
{
|
||||
return preg_match('/^[a-f0-9]{32}$/', $publicKey) === 1
|
||||
&& Db::name('qywx_promotion_pool')->where('public_key', $publicKey)->whereNull('delete_time')->count() > 0;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $links */
|
||||
private static function weightedRandom(array $links): ?array
|
||||
{
|
||||
if ($links === []) {
|
||||
return null;
|
||||
}
|
||||
$total = array_sum(array_map(static fn (array $row): int => max(1, (int) ($row['weight'] ?? 1)), $links));
|
||||
$needle = random_int(1, max(1, $total));
|
||||
foreach ($links as $link) {
|
||||
$needle -= max(1, (int) ($link['weight'] ?? 1));
|
||||
if ($needle <= 0) {
|
||||
return $link;
|
||||
}
|
||||
}
|
||||
|
||||
return $links[array_key_last($links)];
|
||||
}
|
||||
|
||||
private static function recordClick(int $poolId, int $linkId, array $context, int $now): void
|
||||
{
|
||||
$source = self::safeSource((string) ($context['source_url'] ?? ''));
|
||||
$ip = trim((string) ($context['ip'] ?? ''));
|
||||
$salt = (string) config('qywx_promotion.credential_key', '') ?: (string) config('qywx_promotion.suite_secret', '');
|
||||
Db::name('qywx_promotion_click_log')->insert([
|
||||
'pool_id' => $poolId,
|
||||
'link_id' => $linkId,
|
||||
'source_url' => $source,
|
||||
'referer' => self::safeSource((string) ($context['referer'] ?? '')),
|
||||
'user_agent' => mb_substr((string) ($context['user_agent'] ?? ''), 0, 500),
|
||||
// 未配置服务端密钥时不落 IP,避免使用公开固定盐形成可枚举标识。
|
||||
'ip_hash' => $ip === '' || $salt === '' ? '' : hash_hmac('sha256', $ip, $salt),
|
||||
'click_date' => date('Y-m-d', $now),
|
||||
'create_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function safeSource(string $url): string
|
||||
{
|
||||
$parts = parse_url(trim($url));
|
||||
if (!is_array($parts)) {
|
||||
return '';
|
||||
}
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
$host = strtolower((string) ($parts['host'] ?? ''));
|
||||
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return mb_substr($scheme . '://' . $host . (string) ($parts['path'] ?? ''), 0, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\qywx;
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\RuntimeException;
|
||||
use EasyWeChat\OpenWork\Contracts\SuiteTicket;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 将企业微信每十分钟推送的 suite_ticket 加密持久化,避免进程/缓存重启后丢失。 */
|
||||
class QywxPromotionSuiteTicket implements SuiteTicket
|
||||
{
|
||||
public function __construct(private readonly string $suiteId)
|
||||
{
|
||||
}
|
||||
|
||||
public function getTicket(): string
|
||||
{
|
||||
$cipher = (string) (Db::name('qywx_promotion_provider_state')
|
||||
->where('suite_id', $this->suiteId)
|
||||
->value('suite_ticket_cipher') ?? '');
|
||||
if ($cipher === '') {
|
||||
throw new RuntimeException('No suite_ticket found. 请先在企业微信服务商后台配置并验证应用指令回调。');
|
||||
}
|
||||
|
||||
return QywxPromotionCredentialCipher::decrypt($cipher);
|
||||
}
|
||||
|
||||
public function setTicket(string $ticket): static
|
||||
{
|
||||
$now = time();
|
||||
$cipher = QywxPromotionCredentialCipher::encrypt($ticket);
|
||||
$exists = Db::name('qywx_promotion_provider_state')->where('suite_id', $this->suiteId)->find();
|
||||
if ($exists) {
|
||||
Db::name('qywx_promotion_provider_state')->where('suite_id', $this->suiteId)->update([
|
||||
'suite_ticket_cipher' => $cipher,
|
||||
'ticket_received_at' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
} else {
|
||||
Db::name('qywx_promotion_provider_state')->insert([
|
||||
'suite_id' => $this->suiteId,
|
||||
'suite_ticket_cipher' => $cipher,
|
||||
'ticket_received_at' => $now,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
// 企业微信服务商第三方应用配置。所有敏感项仅从服务端环境变量读取,不返回管理端。
|
||||
'enabled' => (bool) env('qywx_promotion.enabled', false),
|
||||
'provider_corp_id' => trim((string) env('qywx_promotion.provider_corp_id', '')),
|
||||
'suite_id' => trim((string) env('qywx_promotion.suite_id', '')),
|
||||
'suite_secret' => trim((string) env('qywx_promotion.suite_secret', '')),
|
||||
'token' => trim((string) env('qywx_promotion.token', '')),
|
||||
'aes_key' => trim((string) env('qywx_promotion.aes_key', '')),
|
||||
|
||||
// 留空时按当前请求域名生成 /api/qywx-promotion/auth/callback。
|
||||
'redirect_uri' => trim((string) env('qywx_promotion.redirect_uri', '')),
|
||||
// 留空时授权完成后返回当前域名 /admin/first_visit/wecom_promotion。
|
||||
'admin_return_url' => trim((string) env('qywx_promotion.admin_return_url', '')),
|
||||
|
||||
// 用于加密永久授权码及 suite_ticket。生产环境建议使用独立的 32 字节以上随机字符串。
|
||||
'credential_key' => trim((string) env('qywx_promotion.credential_key', '')),
|
||||
|
||||
// 防止公开跳转接口被配置成任意外链。可通过逗号分隔的环境变量追加企业自有可信域名。
|
||||
'allowed_link_hosts' => array_values(array_unique(array_filter(array_merge(
|
||||
[
|
||||
'work.weixin.qq.com',
|
||||
'open.work.weixin.qq.com',
|
||||
'workweixin.qq.com',
|
||||
'qyapi.weixin.qq.com',
|
||||
'wework.qpic.cn',
|
||||
'wwcdn.weixin.qq.com',
|
||||
],
|
||||
array_map('trim', explode(',', (string) env('qywx_promotion.allowed_link_hosts', '')))
|
||||
)))),
|
||||
];
|
||||
@@ -0,0 +1,42 @@
|
||||
# 企业微信推广助手配置
|
||||
|
||||
管理端菜单:`一诊 / 企业微信推广助手`
|
||||
|
||||
该功能使用企业微信“服务商第三方应用”授权模式。服务商参数只保存在服务器环境变量中,企业永久授权码会使用 AES-256-GCM 加密后存入数据库。
|
||||
|
||||
在 `server/.env` 中增加:
|
||||
|
||||
```ini
|
||||
[qywx_promotion]
|
||||
ENABLED = true
|
||||
PROVIDER_CORP_ID = "服务商企业 CorpID"
|
||||
SUITE_ID = "第三方应用 SuiteID"
|
||||
SUITE_SECRET = "第三方应用 SuiteSecret"
|
||||
TOKEN = "应用指令回调 Token"
|
||||
AES_KEY = "应用指令回调 EncodingAESKey"
|
||||
CREDENTIAL_KEY = "至少32字节的独立随机密钥"
|
||||
REDIRECT_URI = "https://你的域名/api/qywx-promotion/auth/callback"
|
||||
ADMIN_RETURN_URL = "https://你的域名/admin/first_visit/wecom_promotion"
|
||||
```
|
||||
|
||||
企业微信服务商后台需要配置:
|
||||
|
||||
- 应用指令回调 URL:`https://你的域名/api/qywx-promotion/provider/callback`
|
||||
- 授权完成回调域名:与你的 `REDIRECT_URI` 域名一致
|
||||
|
||||
保存配置并让企业微信成功推送一次 `suite_ticket` 后,管理端的“发起企业授权”按钮才会生成安装链接。
|
||||
|
||||
推广链接默认仅允许企业微信官方域名。如需跳转企业自有的可信中间页,可在环境变量中追加:
|
||||
|
||||
```ini
|
||||
ALLOWED_LINK_HOSTS = "promo.example.com,crm.example.com"
|
||||
```
|
||||
|
||||
公开 JS 示例:
|
||||
|
||||
```html
|
||||
<script src="https://你的域名/api/qywx-promotion/js/分流方案KEY" defer></script>
|
||||
<a href="#" data-wecom-promotion="分流方案KEY">添加企业微信</a>
|
||||
```
|
||||
|
||||
随机分流在服务端完成。候选链接必须同时满足:方案启用、链接上线、授权企业有效、处于有效时间段、未超过当日上限。权重越大,被选中的概率越高。
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import t from"./error-Pzo5SbGa.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-9dA1PEvN.js";import"./index-BWzN7QIb.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
import t from"./error-B8hA_PO-.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-CJoARskO.js";import"./index-DGIIeNfq.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import e from"./error-Pzo5SbGa.js";import{o,q as r,r as t,v as s}from"./.pnpm-9dA1PEvN.js";import"./index-BWzN7QIb.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
import e from"./error-B8hA_PO-.js";import{o,q as r,r as t,v as s}from"./.pnpm-CJoARskO.js";import"./index-DGIIeNfq.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.cell-stack[data-v-7005d187]{display:flex;flex-direction:column;gap:2px;line-height:1.35}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-9dA1PEvN.js";import{a as V}from"./doctor-Chj6BcVp.js";import{m as A,_ as M}from"./index-BWzN7QIb.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-7005d187"]]);export{Q as default};
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-CJoARskO.js";import{a as V}from"./doctor-CDLpp8Jc.js";import{m as A,_ as M}from"./index-DGIIeNfq.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
@@ -0,0 +1 @@
|
||||
.cell-stack[data-v-4de87dfa]{display:flex;flex-direction:column;gap:2px;line-height:1.35}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-9dA1PEvN.js";import{a9 as V}from"./tcm-Fi_swgCr.js";import{_ as q}from"./index-BWzN7QIb.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-0a09e3d2"]]);export{Y as default};
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-CJoARskO.js";import{aa as V}from"./tcm-BCWrWaNC.js";import{_ as q}from"./index-DGIIeNfq.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
@@ -1 +0,0 @@
|
||||
.assign-log-col-hint[data-v-0a09e3d2]{margin-left:4px;vertical-align:middle;color:var(--el-text-color-secondary);cursor:help}
|
||||
@@ -0,0 +1 @@
|
||||
.assign-log-col-hint[data-v-f670e3e6]{margin-left:4px;vertical-align:middle;color:var(--el-text-color-secondary);cursor:help}
|
||||
@@ -0,0 +1 @@
|
||||
.watch-state[data-v-7aff665d]{min-height:200px;display:flex;align-items:center;justify-content:center;color:var(--el-text-color-secondary);font-size:14px}.watch-error[data-v-7aff665d]{color:var(--el-color-danger)}.watch-grid[data-v-7aff665d]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;min-height:220px}.watch-tile[data-v-7aff665d]{background:#0f0f0f;border-radius:8px;overflow:hidden;aspect-ratio:16 / 10;display:flex;flex-direction:column}.watch-tile-cap[data-v-7aff665d]{padding:6px 10px;font-size:12px;color:#e5e5e5;background:#0000008c}.watch-tile-view[data-v-7aff665d]{flex:1;min-height:0;position:relative}.watch-hint[data-v-7aff665d]{float:left;line-height:32px;font-size:12px;color:var(--el-text-color-secondary)}
|
||||
@@ -1 +0,0 @@
|
||||
.watch-state[data-v-8c719418]{min-height:200px;display:flex;align-items:center;justify-content:center;color:var(--el-text-color-secondary);font-size:14px}.watch-error[data-v-8c719418]{color:var(--el-color-danger)}.watch-grid[data-v-8c719418]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;min-height:220px}.watch-tile[data-v-8c719418]{background:#0f0f0f;border-radius:8px;overflow:hidden;aspect-ratio:16 / 10;display:flex;flex-direction:column}.watch-tile-cap[data-v-8c719418]{padding:6px 10px;font-size:12px;color:#e5e5e5;background:#0000008c}.watch-tile-view[data-v-8c719418]{flex:1;min-height:0;position:relative}.watch-hint[data-v-8c719418]{float:left;line-height:32px;font-size:12px;color:var(--el-text-color-secondary)}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,cX as d}from"./.pnpm-9dA1PEvN.js";import{aa as Y}from"./tcm-Fi_swgCr.js";import{_ as q}from"./index-BWzN7QIb.js";const X={key:0,class:"watch-state"},z={key:1,class:"watch-state watch-error"},F=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),p=v("旁观视频通话"),n=new Map;let a=null,f=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==d.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const c=document.createElement("div");c.className="watch-tile-view",o.appendChild(s),o.appendChild(c),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:c})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(d.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(d.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(d.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(d.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++f;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",p.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==f)return;e.patientName&&(p.value=`旁观视频通话 · ${e.patientName}`),a=d.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==f){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",p.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){f++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=c=>y.value=c),title:p.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=c=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",X,"正在连接房间…")):i.value?(E(),_("div",z,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(F,[["__scopeId","data-v-8c719418"]]);export{Z as default};
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,de as c}from"./.pnpm-CJoARskO.js";import{ab as Y}from"./tcm-BCWrWaNC.js";import{_ as q}from"./index-DGIIeNfq.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(M){console.warn("[AssistantWatchCall] startRemoteVideo",M)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function b(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:b},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.blood-record-list[data-v-3a187401]{padding:20px}
|
||||
@@ -1 +0,0 @@
|
||||
.blood-record-list[data-v-002163f9]{padding:20px}
|
||||
@@ -0,0 +1 @@
|
||||
.call-record-panel .call-record-toolbar[data-v-78d5c9e4]{display:flex;align-items:center;gap:12px;margin-bottom:12px}.call-record-panel .call-record-empty[data-v-78d5c9e4]{padding:28px 12px;color:var(--el-text-color-secondary);text-align:center}.call-record-panel .call-record-empty__title[data-v-78d5c9e4]{font-size:14px;color:var(--el-text-color-primary)}.call-record-panel .call-record-empty__desc[data-v-78d5c9e4]{margin-top:8px;font-size:12px;line-height:1.6}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as N,cZ as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-9dA1PEvN.js";import j from"./RecordingPlaybackBlock-CN4w1UI-.js";import{U as x}from"./index-DFkJDhf6.js";import{i as c,_ as q}from"./index-BWzN7QIb.js";import{ae as K,af as k,ag as Z}from"./tcm-Fi_swgCr.js";import"./RecordingVideoPlayer-DIfduEP7.js";import"./file-BL6iPgPC.js";const A={class:"call-record-panel"},F={key:0,class:"call-record-toolbar"},G={class:"call-record-empty"},H={class:"call-record-empty__desc"},J={key:0,class:"text-primary"},Q={key:1,class:"text-gray-400"},W=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await Z({diagnosis_id:r.diagnosisId});await k({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await k({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",A,[_.readOnly?h("",!0):(d(),f("div",F,[o(x,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",G,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",H,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",J,u(s.room_id),1)):(d(),f("span",Q,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(x,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(W,[["__scopeId","data-v-41737096"]]);export{sa as default};
|
||||
import{o as N,dg as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-CJoARskO.js";import j from"./RecordingPlaybackBlock-Sc657sK5.js";import{U as x}from"./index-D30MriBb.js";import{i as c,_ as q}from"./index-DGIIeNfq.js";import{af as K,ag as k,ah as A}from"./tcm-BCWrWaNC.js";import"./RecordingVideoPlayer-qwMasORO.js";import"./file-DApWCRJt.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await k({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await k({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(x,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(x,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
@@ -1 +0,0 @@
|
||||
.call-record-panel .call-record-toolbar[data-v-41737096]{display:flex;align-items:center;gap:12px;margin-bottom:12px}.call-record-panel .call-record-empty[data-v-41737096]{padding:28px 12px;color:var(--el-text-color-secondary);text-align:center}.call-record-panel .call-record-empty__title[data-v-41737096]{font-size:14px;color:var(--el-text-color-primary)}.call-record-panel .call-record-empty__desc[data-v-41737096]{margin-top:8px;font-size:12px;line-height:1.6}
|
||||
@@ -0,0 +1 @@
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-CJoARskO.js";import{ai as q}from"./tcm-BCWrWaNC.js";import{_ as H}from"./index-DGIIeNfq.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
@@ -0,0 +1 @@
|
||||
.case-record-list[data-v-043d2738]{padding:20px}
|
||||
@@ -1 +0,0 @@
|
||||
.case-record-list[data-v-da9a20f3]{padding:20px}
|
||||
@@ -1 +0,0 @@
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as s,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as g,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-9dA1PEvN.js";import{ah as q}from"./tcm-Fi_swgCr.js";import{_ as H}from"./index-BWzN7QIb.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[s(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),g(E,{data:p(r),border:""},{default:i(()=>[s(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),s(n,{prop:"visit_no",label:"门诊号",width:"120"}),s(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),s(n,{label:"处方摘要","min-width":"180"},{default:i(({row:a})=>[a.herbs&&a.herbs.length?(o(),l("span",G,_(a.herbs.slice(0,3).map(f=>`${f.name}${f.dosage}克`).join("、"))+_(a.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),s(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),s(n,{label:"状态",width:"140",align:"center"},{default:i(({row:a})=>[a.void_status===1?(o(),l(P,{key:0},[s(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(a.void_by_name||"—")+" "+_(S(a.void_time)),1)],64)):(o(),g(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),s(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:a})=>[s(h,{link:"",type:"primary",size:"small",onClick:f=>B(a)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),g(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-da9a20f3"]]);export{ee as default};
|
||||
@@ -1 +0,0 @@
|
||||
.daily-matrix[data-v-a5368e74]{padding:16px}.daily-matrix__toolbar[data-v-a5368e74]{display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:12px}.daily-matrix__toolbar-left[data-v-a5368e74],.daily-matrix__toolbar-right[data-v-a5368e74]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.daily-matrix__table[data-v-a5368e74],.daily-matrix__table-wrap[data-v-a5368e74]{width:100%}.daily-matrix__chart[data-v-a5368e74]{margin-top:16px;padding:16px 18px;border:1px solid var(--el-border-color-lighter);border-radius:10px;background:linear-gradient(180deg,#fff,#f8fafc)}.daily-matrix__chart-canvas[data-v-a5368e74]{height:280px;width:100%}.daily-matrix__cell[data-v-a5368e74]{min-height:32px;display:inline-flex;align-items:center;justify-content:center;gap:2px;width:100%;color:var(--el-text-color-regular)}.daily-matrix__cell.is-clickable[data-v-a5368e74]{cursor:pointer}.daily-matrix__cell.is-empty[data-v-a5368e74]{color:var(--el-text-color-placeholder)}.daily-matrix__cell.is-high[data-v-a5368e74]{color:#dc2626;font-weight:700}.daily-matrix__cell.is-patient-self[data-v-a5368e74]{position:relative;background:linear-gradient(180deg,#8b5cf60a,#8b5cf61a);border-radius:4px}.daily-matrix__cell-up[data-v-a5368e74]{color:#dc2626;font-size:13px}.daily-matrix__cell-patient[data-v-a5368e74]{display:inline-block;margin-left:4px;padding:1px 6px;font-size:11px;font-weight:600;color:#6d28d9;background:#ede9fe;border:1px solid #ddd6fe;border-radius:999px;line-height:1.2;letter-spacing:.5px;white-space:nowrap}.daily-matrix__legend[data-v-a5368e74]{display:inline-flex;align-items:center;gap:6px;margin-right:12px;padding:2px 10px 2px 6px;background:#f8f6ff;border:1px dashed #ddd6fe;border-radius:999px}.daily-matrix__legend-text[data-v-a5368e74]{font-size:12px;color:#6d28d9;font-weight:500}.daily-matrix__todo[data-v-a5368e74]{margin-top:16px}.daily-matrix__section-title[data-v-a5368e74]{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--el-text-color-primary)}.daily-matrix__tracking-existing[data-v-a5368e74]{width:100%;max-height:180px;overflow:auto;padding:8px 10px;border:1px solid var(--el-border-color);border-radius:6px;background:var(--el-fill-color-light)}.daily-matrix__tracking-line[data-v-a5368e74]{font-size:12.5px;line-height:1.6;color:var(--el-text-color-regular);word-break:break-word}.daily-matrix__tracking-preview[data-v-a5368e74]{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;width:100%;text-align:left}.daily-matrix__tracking-tooltip[data-v-a5368e74]{max-width:320px}.daily-matrix__tracking-tooltip-line[data-v-a5368e74]{font-size:12.5px;line-height:1.6;word-break:break-word}@media (max-width: 768px){.daily-matrix[data-v-a5368e74],.daily-matrix__chart[data-v-a5368e74]{padding:12px}.daily-matrix__chart-canvas[data-v-a5368e74]{height:240px}}
|
||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.daily-matrix[data-v-4d566204]{padding:16px}.daily-matrix__toolbar[data-v-4d566204]{display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:12px}.daily-matrix__toolbar-left[data-v-4d566204],.daily-matrix__toolbar-right[data-v-4d566204]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.daily-matrix__table[data-v-4d566204],.daily-matrix__table-wrap[data-v-4d566204]{width:100%}.daily-matrix__chart[data-v-4d566204]{margin-top:16px;padding:16px 18px;border:1px solid var(--el-border-color-lighter);border-radius:10px;background:linear-gradient(180deg,#fff,#f8fafc)}.daily-matrix__chart-canvas[data-v-4d566204]{height:280px;width:100%}.daily-matrix__cell[data-v-4d566204]{min-height:32px;display:inline-flex;align-items:center;justify-content:center;gap:2px;width:100%;color:var(--el-text-color-regular)}.daily-matrix__cell.is-clickable[data-v-4d566204]{cursor:pointer}.daily-matrix__cell.is-empty[data-v-4d566204]{color:var(--el-text-color-placeholder)}.daily-matrix__cell.is-high[data-v-4d566204]{color:#dc2626;font-weight:700}.daily-matrix__cell.is-patient-self[data-v-4d566204]{position:relative;background:linear-gradient(180deg,#8b5cf60a,#8b5cf61a);border-radius:4px}.daily-matrix__cell-up[data-v-4d566204]{color:#dc2626;font-size:13px}.daily-matrix__cell-patient[data-v-4d566204]{display:inline-block;margin-left:4px;padding:1px 6px;font-size:11px;font-weight:600;color:#6d28d9;background:#ede9fe;border:1px solid #ddd6fe;border-radius:999px;line-height:1.2;letter-spacing:.5px;white-space:nowrap}.daily-matrix__legend[data-v-4d566204]{display:inline-flex;align-items:center;gap:6px;margin-right:12px;padding:2px 10px 2px 6px;background:#f8f6ff;border:1px dashed #ddd6fe;border-radius:999px}.daily-matrix__legend-text[data-v-4d566204]{font-size:12px;color:#6d28d9;font-weight:500}.daily-matrix__todo[data-v-4d566204]{margin-top:16px}.daily-matrix__section-title[data-v-4d566204]{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--el-text-color-primary)}.daily-matrix__tracking-existing[data-v-4d566204]{width:100%;max-height:180px;overflow:auto;padding:8px 10px;border:1px solid var(--el-border-color);border-radius:6px;background:var(--el-fill-color-light)}.daily-matrix__tracking-line[data-v-4d566204]{font-size:12.5px;line-height:1.6;color:var(--el-text-color-regular);word-break:break-word}.daily-matrix__tracking-preview[data-v-4d566204]{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;width:100%;text-align:left}.daily-matrix__tracking-tooltip[data-v-4d566204]{max-width:320px}.daily-matrix__tracking-tooltip-line[data-v-4d566204]{font-size:12.5px;line-height:1.6;word-break:break-word}@media (max-width: 768px){.daily-matrix[data-v-4d566204],.daily-matrix__chart[data-v-4d566204]{padding:12px}.daily-matrix__chart-canvas[data-v-4d566204]{height:240px}}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.diagnosis-todo-list .toolbar[data-v-c136d72f]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px}.diagnosis-todo-list .todo-table[data-v-c136d72f]{width:100%}.diagnosis-todo-list .pagination-wrap[data-v-c136d72f]{margin-top:12px;display:flex;justify-content:flex-end}.diagnosis-todo-list .text-danger[data-v-c136d72f]{color:var(--el-color-danger)}.diagnosis-todo-list .text-muted[data-v-c136d72f]{color:var(--el-text-color-placeholder)}
|
||||
@@ -1 +0,0 @@
|
||||
.diagnosis-todo-list .toolbar[data-v-e71c3261]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px}.diagnosis-todo-list .todo-table[data-v-e71c3261]{width:100%}.diagnosis-todo-list .pagination-wrap[data-v-e71c3261]{margin-top:12px;display:flex;justify-content:flex-end}.diagnosis-todo-list .text-danger[data-v-e71c3261]{color:var(--el-color-danger)}.diagnosis-todo-list .text-muted[data-v-e71c3261]{color:var(--el-text-color-placeholder)}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.diet-record-list[data-v-2ef076d1]{padding:20px}
|
||||
@@ -0,0 +1 @@
|
||||
.diet-record-list[data-v-7c66f363]{padding:20px}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.exercise-record-list[data-v-ab6f0ec7]{padding:20px}
|
||||
@@ -1 +0,0 @@
|
||||
.exercise-record-list[data-v-6a7f7e37]{padding:20px}
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-DoPnp_YZ.js";import"./.pnpm-9dA1PEvN.js";import"./tcm-Fi_swgCr.js";import"./index-BWzN7QIb.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-DtlknZDO.js";import"./.pnpm-CJoARskO.js";import"./tcm-BCWrWaNC.js";import"./index-DGIIeNfq.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-9dA1PEvN.js";import{p as j}from"./tcm-Fi_swgCr.js";import{i as C}from"./index-BWzN7QIb.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-CJoARskO.js";import{p as j}from"./tcm-BCWrWaNC.js";import{i as C}from"./index-DGIIeNfq.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
@@ -1 +0,0 @@
|
||||
.im-chat-record-panel[data-v-e57593a0]{min-height:200px}.panel-tip[data-v-e57593a0]{margin:0;line-height:1.55;font-size:13px}.panel-tip code[data-v-e57593a0]{font-size:12px}.toolbar[data-v-e57593a0]{display:flex;align-items:center}.chat-wrap[data-v-e57593a0]{min-height:120px}.chat-list[data-v-e57593a0]{display:flex;flex-direction:column;gap:16px;max-height:min(60vh,520px);overflow-y:auto;padding:4px 8px 12px}.chat-row[data-v-e57593a0]{display:flex;flex-direction:column;max-width:88%}.chat-row.from-patient[data-v-e57593a0]{align-self:flex-start}.chat-row.from-patient .bubble[data-v-e57593a0]{background:var(--el-fill-color-light);border:1px solid var(--el-border-color-lighter)}.chat-row.from-doctor[data-v-e57593a0]{align-self:flex-end;align-items:flex-end}.chat-row.from-doctor .meta[data-v-e57593a0]{flex-direction:row-reverse}.chat-row.from-doctor .bubble[data-v-e57593a0]{background:var(--el-color-primary-light-9);border:1px solid var(--el-color-primary-light-7)}.meta[data-v-e57593a0]{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--el-text-color-secondary);margin-bottom:6px}.meta .name[data-v-e57593a0]{font-weight:500;color:var(--el-text-color-regular)}.bubble[data-v-e57593a0]{border-radius:8px;padding:10px 12px;word-break:break-word;font-size:14px;line-height:1.5}.text-content[data-v-e57593a0]{white-space:pre-wrap}.chat-img[data-v-e57593a0]{max-width:240px;max-height:200px;border-radius:4px}.muted[data-v-e57593a0]{color:var(--el-text-color-placeholder)}.friendly-text .friendly-main[data-v-e57593a0]{font-size:14px;line-height:1.5;color:var(--el-text-color-primary)}.friendly-text .friendly-sub[data-v-e57593a0]{margin-top:6px;font-size:12px;line-height:1.4;color:var(--el-text-color-secondary)}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,c_ as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as ae,a8 as se,E as C}from"./.pnpm-9dA1PEvN.js";import{d as te}from"./dayjs-BsOAYQJw.js";import{am as ne,an as oe}from"./tcm-Fi_swgCr.js";import{p as re}from"./im-business-message-parse-DmDISFGN.js";import{_ as le}from"./index-BWzN7QIb.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=se([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const s=P(e);let l="";return s!=null&&s.tag?l=s.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:s,tag:l}}));function N(e){if(e==null||!e)return"—";const s=e>1e12?Math.floor(e/1e3):e;return te.unix(s).format("YYYY-MM-DD HH:mm:ss")}function P(e){const s=(e.text||"").trim();if(!s)return null;const l=s.startsWith("{")&&(/\bbusinessID\b/.test(s)||/\bcmd\b/.test(s));return e.msg_type==="custom"||l?re(s):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,s)=>{const l=G,k=H,I=j,T=Z,Y=ee,V=ae,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...s[0]||(s[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),s[1]||(s[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),s[2]||(s[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,a=>(t(),n("div",{key:a.raw.msg_id,class:X(["chat-row",a.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(a.raw)),1),o("span",me,r(N(a.raw.time)),1),a.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(a.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[a.raw.msg_type==="image"&&a.raw.image_url?(t(),f(Y,{key:0,src:a.raw.image_url,"preview-src-list":[a.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(a.raw.msg_type==="file"||a.raw.msg_type==="sound"||a.raw.msg_type==="video")&&a.raw.file_url?(t(),f(V,{key:1,href:a.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(a.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[a.friendly?(t(),n("div",ye,[o("div",ge,r(a.friendly.main),1),a.friendly.sub?(t(),n("div",ve,r(a.friendly.sub),1)):v("",!0)])):a.raw.msg_type==="text"&&a.raw.text?(t(),n("div",he,r(a.raw.text),1)):a.raw.text?(t(),n("div",we,r(a.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-e57593a0"]]);export{Me as default};
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as ae,a8 as se,E as C}from"./.pnpm-CJoARskO.js";import{d as te}from"./dayjs-HJJmW-S4.js";import{an as ne,ao as oe}from"./tcm-BCWrWaNC.js";import{p as re}from"./im-business-message-parse-DTpJoEdY.js";import{_ as le}from"./index-DGIIeNfq.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=se([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const s=P(e);let l="";return s!=null&&s.tag?l=s.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:s,tag:l}}));function N(e){if(e==null||!e)return"—";const s=e>1e12?Math.floor(e/1e3):e;return te.unix(s).format("YYYY-MM-DD HH:mm:ss")}function P(e){const s=(e.text||"").trim();if(!s)return null;const l=s.startsWith("{")&&(/\bbusinessID\b/.test(s)||/\bcmd\b/.test(s));return e.msg_type==="custom"||l?re(s):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,s)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=ae,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...s[0]||(s[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),s[1]||(s[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),s[2]||(s[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,a=>(t(),n("div",{key:a.raw.msg_id,class:X(["chat-row",a.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(a.raw)),1),o("span",me,r(N(a.raw.time)),1),a.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(a.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[a.raw.msg_type==="image"&&a.raw.image_url?(t(),f(V,{key:0,src:a.raw.image_url,"preview-src-list":[a.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(a.raw.msg_type==="file"||a.raw.msg_type==="sound"||a.raw.msg_type==="video")&&a.raw.file_url?(t(),f(Y,{key:1,href:a.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(a.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[a.friendly?(t(),n("div",ye,[o("div",ge,r(a.friendly.main),1),a.friendly.sub?(t(),n("div",ve,r(a.friendly.sub),1)):v("",!0)])):a.raw.msg_type==="text"&&a.raw.text?(t(),n("div",he,r(a.raw.text),1)):a.raw.text?(t(),n("div",we,r(a.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
@@ -0,0 +1 @@
|
||||
.im-chat-record-panel[data-v-58b699dd]{min-height:200px}.panel-tip[data-v-58b699dd]{margin:0;line-height:1.55;font-size:13px}.panel-tip code[data-v-58b699dd]{font-size:12px}.toolbar[data-v-58b699dd]{display:flex;align-items:center}.chat-wrap[data-v-58b699dd]{min-height:120px}.chat-list[data-v-58b699dd]{display:flex;flex-direction:column;gap:16px;max-height:min(60vh,520px);overflow-y:auto;padding:4px 8px 12px}.chat-row[data-v-58b699dd]{display:flex;flex-direction:column;max-width:88%}.chat-row.from-patient[data-v-58b699dd]{align-self:flex-start}.chat-row.from-patient .bubble[data-v-58b699dd]{background:var(--el-fill-color-light);border:1px solid var(--el-border-color-lighter)}.chat-row.from-doctor[data-v-58b699dd]{align-self:flex-end;align-items:flex-end}.chat-row.from-doctor .meta[data-v-58b699dd]{flex-direction:row-reverse}.chat-row.from-doctor .bubble[data-v-58b699dd]{background:var(--el-color-primary-light-9);border:1px solid var(--el-color-primary-light-7)}.meta[data-v-58b699dd]{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--el-text-color-secondary);margin-bottom:6px}.meta .name[data-v-58b699dd]{font-weight:500;color:var(--el-text-color-regular)}.bubble[data-v-58b699dd]{border-radius:8px;padding:10px 12px;word-break:break-word;font-size:14px;line-height:1.5}.text-content[data-v-58b699dd]{white-space:pre-wrap}.chat-img[data-v-58b699dd]{max-width:240px;max-height:200px;border-radius:4px}.muted[data-v-58b699dd]{color:var(--el-text-color-placeholder)}.friendly-text .friendly-main[data-v-58b699dd]{font-size:14px;line-height:1.5;color:var(--el-text-color-primary)}.friendly-text .friendly-sub[data-v-58b699dd]{margin-top:6px;font-size:12px;line-height:1.4;color:var(--el-text-color-secondary)}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-B8TP8hmm.js";import"./.pnpm-9dA1PEvN.js";export{m as default};
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-iYm8xAAz.js";import"./.pnpm-CJoARskO.js";export{m as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-9dA1PEvN.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-CJoARskO.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
@@ -0,0 +1 @@
|
||||
.msg-bubble[data-v-b5ff1168]{display:flex;gap:10px;padding:10px 20px;align-items:flex-start}.msg-bubble.is-staff[data-v-b5ff1168]{flex-direction:row-reverse}.msg-bubble.is-staff .msg-body[data-v-b5ff1168]{align-items:flex-end}.msg-bubble.is-staff .msg-content.bubble-text[data-v-b5ff1168],.msg-bubble.is-staff .msg-content.bubble-file[data-v-b5ff1168]{background:#95ec69;color:#000}.msg-bubble .msg-avatar[data-v-b5ff1168]{flex-shrink:0;background:#d0d7de;color:#fff;font-size:12px}.msg-bubble .msg-body[data-v-b5ff1168]{display:flex;flex-direction:column;gap:4px;max-width:65%;min-width:0}.msg-bubble .msg-meta[data-v-b5ff1168]{display:flex;gap:8px;align-items:center;font-size:12px;color:#999}.msg-bubble .msg-sender[data-v-b5ff1168]{font-weight:500;color:#555}.msg-bubble .msg-content[data-v-b5ff1168]{padding:8px 12px;border-radius:6px;background:#fff;word-break:break-word;line-height:1.6;max-width:100%}.msg-bubble .msg-content.bubble-media[data-v-b5ff1168]{padding:0;background:transparent}.msg-bubble .content-text[data-v-b5ff1168]{white-space:pre-wrap}.msg-bubble .content-image[data-v-b5ff1168]{max-width:300px;max-height:300px;border-radius:6px;display:block}.msg-bubble .content-video[data-v-b5ff1168]{max-width:320px;max-height:320px;border-radius:6px;display:block}.msg-bubble .content-audio[data-v-b5ff1168]{max-width:260px}.msg-bubble .content-file[data-v-b5ff1168]{display:flex;gap:12px;align-items:center;min-width:220px;padding:4px 8px}.msg-bubble .content-file .file-meta[data-v-b5ff1168]{flex:1;min-width:0}.msg-bubble .content-file .file-name[data-v-b5ff1168]{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.msg-bubble .content-file .file-info[data-v-b5ff1168]{font-size:12px;color:#888}.msg-bubble .content-pending[data-v-b5ff1168]{display:flex;gap:6px;align-items:center;color:#999;font-size:13px}.msg-bubble .content-fallback .fallback-title[data-v-b5ff1168]{font-size:13px;color:#666;margin-bottom:4px}.msg-bubble .content-fallback .fallback-desc[data-v-b5ff1168]{font-size:14px;color:#333}
|
||||
@@ -1 +0,0 @@
|
||||
.msg-bubble[data-v-80799394]{display:flex;gap:10px;padding:10px 20px;align-items:flex-start}.msg-bubble.is-staff[data-v-80799394]{flex-direction:row-reverse}.msg-bubble.is-staff .msg-body[data-v-80799394]{align-items:flex-end}.msg-bubble.is-staff .msg-content.bubble-text[data-v-80799394],.msg-bubble.is-staff .msg-content.bubble-file[data-v-80799394]{background:#95ec69;color:#000}.msg-bubble .msg-avatar[data-v-80799394]{flex-shrink:0;background:#d0d7de;color:#fff;font-size:12px}.msg-bubble .msg-body[data-v-80799394]{display:flex;flex-direction:column;gap:4px;max-width:65%;min-width:0}.msg-bubble .msg-meta[data-v-80799394]{display:flex;gap:8px;align-items:center;font-size:12px;color:#999}.msg-bubble .msg-sender[data-v-80799394]{font-weight:500;color:#555}.msg-bubble .msg-content[data-v-80799394]{padding:8px 12px;border-radius:6px;background:#fff;word-break:break-word;line-height:1.6;max-width:100%}.msg-bubble .msg-content.bubble-media[data-v-80799394]{padding:0;background:transparent}.msg-bubble .content-text[data-v-80799394]{white-space:pre-wrap}.msg-bubble .content-image[data-v-80799394]{max-width:300px;max-height:300px;border-radius:6px;display:block}.msg-bubble .content-video[data-v-80799394]{max-width:320px;max-height:320px;border-radius:6px;display:block}.msg-bubble .content-audio[data-v-80799394]{max-width:260px}.msg-bubble .content-file[data-v-80799394]{display:flex;gap:12px;align-items:center;min-width:220px;padding:4px 8px}.msg-bubble .content-file .file-meta[data-v-80799394]{flex:1;min-width:0}.msg-bubble .content-file .file-name[data-v-80799394]{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.msg-bubble .content-file .file-info[data-v-80799394]{font-size:12px;color:#888}.msg-bubble .content-pending[data-v-80799394]{display:flex;gap:6px;align-items:center;color:#999;font-size:13px}.msg-bubble .content-fallback .fallback-title[data-v-80799394]{font-size:13px;color:#666;margin-bottom:4px}.msg-bubble .content-fallback .fallback-desc[data-v-80799394]{font-size:14px;color:#333}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-9dA1PEvN.js";import{t as j,_ as J}from"./index-BWzN7QIb.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var b,y,p,h;const T=D,k=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(k,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((b=c.value)!=null&&b.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(k,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-80799394"]]);export{ve as default};
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-CJoARskO.js";import{t as j,_ as J}from"./index-DGIIeNfq.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,cO as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-9dA1PEvN.js";import{_ as fe}from"./picker-DfI3CPnQ.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-BWzN7QIb.js";import{a as T,d as he}from"./patient-Btpwd7dp.js";import{h as ke}from"./perm-BzF8JgcJ.js";import"./index-dYaCh-CN.js";import"./index-xDQ_OZln.js";import"./index.vue_vue_type_script_setup_true_lang-DbpIG4p5.js";import"./index-fA0EWEUO.js";import"./index-DFkJDhf6.js";import"./file-BL6iPgPC.js";import"./index.vue_vue_type_script_setup_true_lang-Blf417m7.js";import"./usePaging-Boz1Q6w3.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,O;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(G=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(O=s.report_files)!=null&&O.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(G=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(G=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-e548427d"]]);export{Ke as default};
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d5 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-CJoARskO.js";import{_ as fe}from"./picker-HtQX0ZkW.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-DGIIeNfq.js";import{a as T,d as he}from"./patient-B5KvakhF.js";import{h as ke}from"./perm-DQosAa6e.js";import"./index-CMaTIYFB.js";import"./index-yt_wDMDM.js";import"./index.vue_vue_type_script_setup_true_lang-CI_v6sRz.js";import"./index-Dxb7lLkm.js";import"./index-D30MriBb.js";import"./file-DApWCRJt.js";import"./index.vue_vue_type_script_setup_true_lang-rPwCQIWi.js";import"./usePaging-DKtm3XdH.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,G;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(G=s.report_files)!=null&&G.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
|
||||
@@ -0,0 +1 @@
|
||||
.timeline-actions[data-v-530ad386]{display:flex;flex-direction:column;align-items:flex-start;gap:12px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px dashed #ebeef5}.upload-trigger[data-v-530ad386]{width:90px;height:90px;border:1px dashed #dcdfe6;border-radius:6px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;cursor:pointer;color:#909399;font-size:12px;transition:border-color .2s}.upload-trigger[data-v-530ad386]:hover{border-color:#409eff;color:#409eff}.note-timeline[data-v-530ad386]{position:relative;padding-left:22px}.note-timeline[data-v-530ad386]:before{content:"";position:absolute;left:6px;top:4px;bottom:4px;width:2px;background:#ebeef5;border-radius:1px}.timeline-node[data-v-530ad386]{position:relative;padding-bottom:16px}.timeline-node[data-v-530ad386]:last-child{padding-bottom:0}.timeline-dot[data-v-530ad386]{position:absolute;left:-19px;top:5px;width:10px;height:10px;border-radius:50%;background:#409eff;border:2px solid #ecf5ff}.timeline-date[data-v-530ad386]{font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;font-family:IBM Plex Mono,Consolas,monospace}.timeline-body[data-v-530ad386]{display:flex;flex-direction:column;gap:6px}.timeline-content[data-v-530ad386]{display:flex;flex-direction:column;gap:2px}.content-line[data-v-530ad386]{font-size:12.5px;color:#606266;line-height:1.6;word-break:break-word}.timeline-images[data-v-530ad386]{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.images-label[data-v-530ad386]{font-size:12px;color:#909399;flex-shrink:0}.thumb-wrap[data-v-530ad386]{position:relative;display:inline-block}.timeline-thumb[data-v-530ad386]{width:64px;height:64px;border-radius:6px;border:1px solid #ebeef5;cursor:pointer;transition:transform .15s ease}.timeline-thumb[data-v-530ad386]:hover{transform:scale(1.05)}.thumb-delete[data-v-530ad386]{position:absolute;top:-6px;right:-6px;font-size:16px;color:#f56c6c;background:#fff;border-radius:50%;cursor:pointer;display:none}.thumb-wrap:hover .thumb-delete[data-v-530ad386]{display:block}.file-wrap[data-v-530ad386]{position:relative;display:inline-flex;align-items:center}.file-link[data-v-530ad386]{display:inline-flex;align-items:center;gap:4px;padding:6px 10px;border:1px solid #ebeef5;border-radius:6px;text-decoration:none;color:#409eff;font-size:12px;max-width:180px;transition:border-color .2s}.file-link[data-v-530ad386]:hover{border-color:#409eff}.file-name[data-v-530ad386]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px}.file-delete[data-v-530ad386]{position:absolute;top:-6px;right:-6px;font-size:16px;color:#f56c6c;background:#fff;border-radius:50%;cursor:pointer;display:none}.file-wrap:hover .file-delete[data-v-530ad386]{display:block}
|
||||
@@ -1 +0,0 @@
|
||||
.timeline-actions[data-v-e548427d]{display:flex;flex-direction:column;align-items:flex-start;gap:12px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px dashed #ebeef5}.upload-trigger[data-v-e548427d]{width:90px;height:90px;border:1px dashed #dcdfe6;border-radius:6px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;cursor:pointer;color:#909399;font-size:12px;transition:border-color .2s}.upload-trigger[data-v-e548427d]:hover{border-color:#409eff;color:#409eff}.note-timeline[data-v-e548427d]{position:relative;padding-left:22px}.note-timeline[data-v-e548427d]:before{content:"";position:absolute;left:6px;top:4px;bottom:4px;width:2px;background:#ebeef5;border-radius:1px}.timeline-node[data-v-e548427d]{position:relative;padding-bottom:16px}.timeline-node[data-v-e548427d]:last-child{padding-bottom:0}.timeline-dot[data-v-e548427d]{position:absolute;left:-19px;top:5px;width:10px;height:10px;border-radius:50%;background:#409eff;border:2px solid #ecf5ff}.timeline-date[data-v-e548427d]{font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;font-family:IBM Plex Mono,Consolas,monospace}.timeline-body[data-v-e548427d]{display:flex;flex-direction:column;gap:6px}.timeline-content[data-v-e548427d]{display:flex;flex-direction:column;gap:2px}.content-line[data-v-e548427d]{font-size:12.5px;color:#606266;line-height:1.6;word-break:break-word}.timeline-images[data-v-e548427d]{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.images-label[data-v-e548427d]{font-size:12px;color:#909399;flex-shrink:0}.thumb-wrap[data-v-e548427d]{position:relative;display:inline-block}.timeline-thumb[data-v-e548427d]{width:64px;height:64px;border-radius:6px;border:1px solid #ebeef5;cursor:pointer;transition:transform .15s ease}.timeline-thumb[data-v-e548427d]:hover{transform:scale(1.05)}.thumb-delete[data-v-e548427d]{position:absolute;top:-6px;right:-6px;font-size:16px;color:#f56c6c;background:#fff;border-radius:50%;cursor:pointer;display:none}.thumb-wrap:hover .thumb-delete[data-v-e548427d]{display:block}.file-wrap[data-v-e548427d]{position:relative;display:inline-flex;align-items:center}.file-link[data-v-e548427d]{display:inline-flex;align-items:center;gap:4px;padding:6px 10px;border:1px solid #ebeef5;border-radius:6px;text-decoration:none;color:#409eff;font-size:12px;max-width:180px;transition:border-color .2s}.file-link[data-v-e548427d]:hover{border-color:#409eff}.file-name[data-v-e548427d]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px}.file-delete[data-v-e548427d]{position:absolute;top:-6px;right:-6px;font-size:16px;color:#f56c6c;background:#fff;border-radius:50%;cursor:pointer;display:none}.file-wrap:hover .file-delete[data-v-e548427d]{display:block}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.form-grid[data-v-bcdfb21b]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.form-tip[data-v-bcdfb21b]{margin-left:10px;color:#8a95a6;font-size:12px}.danger-menu-item{color:var(--el-color-danger)!important}@media (max-width: 720px){.form-grid[data-v-bcdfb21b]{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1 @@
|
||||
.embedded-panel[data-v-78709492]{min-height:420px}.panel-toolbar[data-v-78709492]{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}.panel-toolbar h2[data-v-78709492]{margin:0;font-size:17px}.panel-toolbar p[data-v-78709492]{margin:4px 0 0;color:#8a95a6;font-size:12px}.toolbar-actions[data-v-78709492],.scope-chip[data-v-78709492]{display:flex;align-items:center;gap:8px}.scope-chip[data-v-78709492]{color:#0f766e;font-size:12px}.filter-panel[data-v-78709492]{display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:14px;border:1px solid #e3e8ef;border-radius:10px;background:#fbfcfd}.filter-panel[data-v-78709492] .el-select{width:132px}.keyword-input[data-v-78709492]{width:min(340px,100%)}.date-range[data-v-78709492]{width:250px}.metric-grid[data-v-78709492]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:14px 0}.metric-card[data-v-78709492]{min-height:82px;padding:14px 16px;border:1px solid #e3e8ef;border-radius:10px;background:#fff}.metric-card span[data-v-78709492],.metric-card small[data-v-78709492]{color:#8a95a6;font-size:12px}.metric-card strong[data-v-78709492]{display:block;margin:7px 0 3px;color:#172033;font-size:22px;line-height:1}.metric-warning[data-v-78709492]{border-color:#f3d8aa;background:#fffcf5}.metric-success[data-v-78709492]{border-color:#b9e2dc;background:#f7fcfb}.embedded-table[data-v-78709492]{width:100%;border:1px solid #e7ebf0;border-radius:9px;overflow:hidden}.embedded-table[data-v-78709492] th.el-table__cell{height:44px;color:#5f6b7d;background:#f7f9fb;font-weight:600}.embedded-table[data-v-78709492] .order-row-risk>td.el-table__cell{background:#fff8f7}.embedded-table[data-v-78709492] .order-row-done>td.el-table__cell{background:#f8fcfb}.primary-cell[data-v-78709492],.id-stack[data-v-78709492]{display:flex;flex-direction:column;gap:3px}.primary-cell strong[data-v-78709492],.id-stack strong[data-v-78709492]{color:#202939;font-size:13px}.primary-cell span[data-v-78709492],.id-stack span[data-v-78709492]{color:#8b96a8;font-size:12px}.amount[data-v-78709492]{color:#d04f3f;font-variant-numeric:tabular-nums}.order-actions[data-v-78709492]{display:flex;align-items:center;gap:4px;white-space:nowrap}.order-actions[data-v-78709492] .el-button+.el-button{margin-left:0}.danger-menu-item{color:var(--el-color-danger)!important}.pagination-wrap[data-v-78709492]{display:flex;justify-content:flex-end;padding-top:16px}@media (max-width: 1080px){.metric-grid[data-v-78709492]{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width: 760px){.panel-toolbar[data-v-78709492]{align-items:flex-start;flex-direction:column}.metric-grid[data-v-78709492]{grid-template-columns:1fr}.filter-panel[data-v-78709492]>*,.filter-panel[data-v-78709492] .el-select,.date-range[data-v-78709492]{width:100%}}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.card[data-v-7b1b11be]{background:var(--rx-surface);border:1px solid var(--rx-line);border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:14px;box-shadow:var(--rx-shadow-sm);transition:box-shadow .2s ease}.card[data-v-7b1b11be]:hover{box-shadow:var(--rx-shadow)}.card-title[data-v-7b1b11be]{font-size:15px;font-weight:700;color:var(--rx-ink);display:flex;align-items:center;gap:10px;padding-left:10px;position:relative}.card-title[data-v-7b1b11be]:before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:16px;border-radius:2px;background:linear-gradient(180deg,#4f8cff,#2563eb)}.case-sub[data-v-7b1b11be]{font-size:12px;font-weight:400;color:var(--rx-soft);font-family:var(--rx-font-data)}.grid-block[data-v-7b1b11be]{display:flex;flex-direction:column;gap:10px;padding-top:12px;border-top:1px dashed var(--rx-line)}.grid-block[data-v-7b1b11be]:first-of-type{border-top:none;padding-top:0}.grid-title[data-v-7b1b11be]{font-size:13px;font-weight:600;color:var(--rx-accent-strong);letter-spacing:.02em;display:flex;align-items:center;gap:6px}.grid-title[data-v-7b1b11be]:before{content:"";width:4px;height:4px;border-radius:50%;background:var(--rx-accent)}.kv-grid[data-v-7b1b11be]{display:grid;gap:8px 16px}.kv-grid.one[data-v-7b1b11be]{grid-template-columns:1fr}.kv-grid.two[data-v-7b1b11be]{grid-template-columns:repeat(2,1fr)}.kv-grid.three[data-v-7b1b11be]{grid-template-columns:repeat(3,1fr)}.kv-grid.four[data-v-7b1b11be]{grid-template-columns:repeat(4,1fr)}[data-v-7b1b11be] .kv-item{display:flex;gap:6px;font-size:12.5px;line-height:1.55;min-width:0}[data-v-7b1b11be] .kv-item.multiline{flex-direction:column;gap:2px}[data-v-7b1b11be] .kv-item .kv-label{color:var(--rx-soft);flex-shrink:0;min-width:58px}[data-v-7b1b11be] .kv-item .kv-value{color:var(--rx-ink);word-break:break-word;flex:1;font-weight:500}[data-v-7b1b11be] .kv-item.empty .kv-value{color:#c0c4cc;font-weight:400}[data-v-7b1b11be] .kv-item.multiline .kv-value{white-space:pre-wrap;font-weight:400}[data-v-7b1b11be] .metric-kv-item .metric-kv-value{display:inline-flex;align-items:center;gap:4px}[data-v-7b1b11be] .metric-kv-item .metric-kv-value.high{color:#dc2626;font-weight:700}[data-v-7b1b11be] .metric-up{font-size:14px;line-height:1;color:#dc2626}.rx-note[data-v-7b1b11be]{font-size:13px;color:var(--rx-muted);line-height:1.6;padding:12px 14px;background:linear-gradient(135deg,#fafbfd,#f5f7fb);border-radius:8px;border-left:3px solid var(--rx-accent)}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user