更新
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user