This commit is contained in:
Your Name
2026-08-05 11:08:02 +08:00
parent 57a7c415a1
commit c2b7018a22
429 changed files with 13316 additions and 350 deletions
+202
View File
@@ -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 })
}
+8
View File
@@ -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 })
}
@@ -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>&lt;/head&gt;</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
+15 -1
View File
@@ -206,10 +206,20 @@ import isoWeek from 'dayjs/plugin/isoWeek'
import { getDoctors } from '@/api/tcm'
import { getDictData } from '@/api/app'
import { getAvailableSlots, createAppointment, rosterLists, appointmentLists } from '@/api/doctor'
import { myPatientCreateAppointment } from '@/api/first_visit'
import feedback from '@/utils/feedback'
dayjs.extend(isoWeek)
const props = withDefaults(
defineProps<{
apiScene?: 'default' | 'my_patient'
}>(),
{
apiScene: 'default'
}
)
interface TimeSlot {
time: string
available: boolean
@@ -703,7 +713,11 @@ const handleConfirm = async () => {
console.log('提交预约参数:', params)
await createAppointment(params)
if (props.apiScene === 'my_patient') {
await myPatientCreateAppointment(params)
} else {
await createAppointment(params)
}
feedback.msgSuccess('预约成功')
visible.value = false
File diff suppressed because one or more lines are too long