Files
xk-doctor-wx/subPackages/sub_business_shared/components/UserPatientDetail.vue
李琦 cb290f3e95 fix(login): 密码登录补充密码非空前端校验
- doctor-login submitLogin 增加「请输入密码」前置校验,
  与 getCode 一致,避免空密码误触发登录请求
- 配合后端 service_user 空密码回退 PC 医生/药师密码校验
2026-07-19 16:08:23 +08:00

557 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 患者视角详情page-container 抽屉避免误退出页面 v-if 控制挂载 -->
<page-container
v-if="visible"
:show="visible"
:overlay="true"
position="bottom"
:round="true"
@beforeleave="onBeforeLeave"
@clickoverlay="close"
>
<view class="drawer">
<view class="drawer-head">
<text class="title">患者视角 · {{ patientName || '就诊人' }}</text>
<text class="close" @click="close">关闭</text>
</view>
<view v-if="loading" class="empty">加载中...</view>
<template v-else>
<!-- 资料卡对齐就诊人详情页 -->
<view class="profile-card">
<view class="profile-row">
<image
class="avatar"
:src="profile.user.avatarurl || '/static/image/default-avatar.png'"
mode="aspectFill"
/>
<view class="meta">
<text class="name">{{ profile.user.nickname || '微信用户' }}</text>
<text class="sub">
ID {{ profile.user.id || '—' }}
<template v-if="profile.user.mobile"> · {{ profile.user.mobile }}</template>
</text>
</view>
</view>
<view class="patient-block">
<view class="patient-head">
<view class="patient-head-left">
<text class="p-name">{{ profile.patient.name || '—' }}</text>
<text v-if="sexText !== '—'" class="sex-tag" :class="sexTagClass">{{ sexText }}</text>
</view>
<!-- 回访拨打就诊人/用户手机 -->
<view class="callback-btn" @click.stop="onCallbackCall">
<text class="callback-btn-text">回访</text>
</view>
</view>
<text class="p-sub">
{{ profile.patient.age || '—' }}
<template v-if="profile.patient.mobile"> · {{ profile.patient.mobile }}</template>
</text>
<text v-if="profile.patient.id_card" class="p-sub">身份证 {{ profile.patient.id_card }}</text>
</view>
</view>
<!-- Tab下划线选中态 -->
<view class="tabs">
<view
v-for="(t, idx) in tabs"
:key="t.key"
class="tab"
:class="{ on: tabIndex === idx }"
@click="selectTab(idx)"
>
<text class="tab-text">{{ t.label }}</text>
<view v-if="tabIndex === idx" class="tab-bar" />
</view>
</view>
<!-- 四页左右滑资料 / 挂号 / 处方 / 订单 -->
<swiper
class="tab-swiper"
:style="{ height: swiperHeightPx + 'px' }"
:current="tabIndex"
:duration="200"
@change="onTabSwiperChange"
>
<swiper-item v-for="t in tabs" :key="'sw-' + t.key">
<scroll-view
scroll-y
class="tab-scroll"
:style="{ height: swiperHeightPx + 'px' }"
>
<!-- 资料不绑 tabLoading避免切列表时闪加载中 -->
<view v-if="t.key === 'info'" class="health-card">
<text class="section-title">健康信息</text>
<template v-if="health">
<view class="info-row">
<text class="info-label">既往史</text>
<text class="info-value">{{ historyText(health.person_status, health.person_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">过敏史</text>
<text
class="info-value"
:class="{ danger: Number(health.allergic_status) !== 0 }"
>{{ historyText(health.allergic_status, health.allergic_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">家族遗传史</text>
<text class="info-value">{{ historyText(health.family_status, health.family_history) }}</text>
</view>
<view class="info-row">
<text class="info-label">肝功能异常</text>
<text
class="info-value"
:class="{ danger: Number(health.liver_function) !== 0 }"
>{{ functionText(health.liver_function, health.liver_index) }}</text>
</view>
<view class="info-row">
<text class="info-label">肾功能异常</text>
<text
class="info-value"
:class="{ danger: Number(health.renal_function) !== 0 }"
>{{ functionText(health.renal_function, health.renal_index) }}</text>
</view>
</template>
<view v-else class="empty-inline">暂无健康问诊记录</view>
</view>
<template v-else>
<view v-if="tabLoading && activeTab === t.key" class="empty">加载中...</view>
<template v-else>
<view v-if="!listForTab(t.key).length" class="empty">暂无记录</view>
<view
v-for="item in listForTab(t.key)"
:key="t.key + '-' + item.id"
class="record-card"
@click="onRecordClick(item, t.key)"
>
<view class="record-main">
<template v-if="t.key === 'register'">
<text class="r-title">{{ item.order_no || item.order_number || '—' }}</text>
<text class="r-sub">{{ item.store || '—' }} · {{ item.doctor || '—' }}</text>
<text class="r-sub">{{ item.status_text || '—' }} · {{ item.created_at || '' }}</text>
</template>
<template v-else-if="t.key === 'prescription'">
<text class="r-title">{{ item.prescription_no || '—' }}</text>
<text class="r-sub">{{ item.store || '—' }} · {{ item.doctor || '—' }}</text>
<text class="r-sub">{{ item.clinical_diagnose || '—' }} · {{ item.created_at || '' }}</text>
</template>
<template v-else>
<text class="r-title">{{ item.order_no || '—' }}</text>
<text class="r-sub">{{ item.store || '—' }} · ¥{{ item.total_pay_price || '—' }}</text>
<text class="r-sub">{{ item.created_at || '' }}</text>
</template>
</view>
<text v-if="t.key === 'register'" class="arrow"></text>
</view>
</template>
</template>
</scroll-view>
</swiper-item>
</swiper>
</template>
</view>
</page-container>
</template>
<script>
/**
* 患者视角详情抽屉:微信用户/就诊人信息 + 挂号/处方/订单
* 对齐详情页展示与回访;四 Tab 支持点击与左右滑动(懒加载)
*/
import {
getUserPatientDetail,
getUserPatientRegisterList,
getUserPatientPrescriptionList,
getUserPatientOrderList,
} from '@/api/userPatientProfile.js';
/** 抽屉左右滑提示只展示一次 */
const SWIPE_TIP_STORAGE_KEY = 'user_patient_drawer_swipe_tip';
export default {
name: 'UserPatientDetail',
props: {
visible: { type: Boolean, default: false },
upId: { type: [Number, String], default: 0 },
patientName: { type: String, default: '' },
},
emits: ['close'],
data() {
return {
loading: false,
tabLoading: false,
activeTab: 'info',
tabIndex: 0,
tabSwiperChanging: false,
swiperHeightPx: 320,
tabs: [
{ key: 'info', label: '资料' },
{ key: 'register', label: '挂号' },
{ key: 'prescription', label: '处方' },
{ key: 'order', label: '订单' },
],
profile: {
user: {},
patient: {},
},
health: null,
registerList: [],
prescriptionList: [],
orderList: [],
};
},
computed: {
sexText() {
const sex = this.profile.patient && this.profile.patient.sex;
if (Number(sex) === 1) return '男';
if (Number(sex) === 2) return '女';
return '—';
},
sexTagClass() {
const sex = this.profile.patient && this.profile.patient.sex;
if (Number(sex) === 1) return 'male';
if (Number(sex) === 2) return 'female';
return '';
},
/**
* 回访拨号:只用就诊人 call_mobile拦截器只解密不脱敏
* 不用脱敏后的 mobile也不回退微信用户号
*/
callbackPhone() {
const patient = this.profile.patient || {};
return String(patient.call_mobile || '').trim();
},
},
watch: {
/**
* v-if 挂载时 visible 已是 true必须 immediate否则 loadDetail 永不触发
*/
visible: {
immediate: true,
handler(val) {
if (val && this.upId) {
this.openAndLoad();
}
},
},
/** 抽屉打开时切换不同就诊人,重新拉详情 */
upId(val) {
if (this.visible && val) {
this.openAndLoad();
}
},
},
methods: {
close() {
this.$emit('close');
},
onBeforeLeave() {
this.close();
},
/**
* 打开抽屉:重置 Tab/列表并请求就诊人详情
*/
openAndLoad() {
this.activeTab = 'info';
this.tabIndex = 0;
this.health = null;
this.registerList = [];
this.prescriptionList = [];
this.orderList = [];
this.profile = { user: {}, patient: {} };
this.calcSwiperHeight();
this.loadDetail();
},
/**
* 抽屉内 swiper 高度80vh 资料卡 Tab保证横向滑动可用
*/
calcSwiperHeight() {
try {
const sys = uni.getSystemInfoSync() || {};
const winH = Number(sys.windowHeight) || 600;
const drawerH = Math.floor(winH * 0.8);
const rpx = (Number(sys.windowWidth) || 375) / 750;
const headH = Math.ceil(100 * rpx);
const profileH = Math.ceil(280 * rpx);
const tabsH = Math.ceil(90 * rpx);
const pad = Math.ceil(24 * rpx);
const h = drawerH - headH - profileH - tabsH - pad;
this.swiperHeightPx = h > 160 ? h : 160;
} catch (e) {
this.swiperHeightPx = 320;
}
},
/** 按 Tab key 取对应列表(四页同时渲染) */
listForTab(key) {
if (key === 'register') return this.registerList;
if (key === 'prescription') return this.prescriptionList;
if (key === 'order') return this.orderList;
return [];
},
/** 既往/过敏/家族0 无,否则展示 history */
historyText(status, history) {
if (Number(status) === 0) return '无';
const t = String(history || '').trim();
return t || '有';
},
/** 肝/肾功能0 正常,异常时附带指标文案 */
functionText(flag, indexText) {
if (Number(flag) === 0) return '正常';
const t = String(indexText || '').trim();
return t ? `异常 · ${t}` : '异常';
},
/**
* 回访:调起系统拨号;无号 toast按钮始终显示
*/
onCallbackCall() {
const phone = this.callbackPhone;
if (!phone) {
uni.showToast({ title: '暂无手机号', icon: 'none' });
return;
}
uni.makePhoneCall({
phoneNumber: phone,
});
},
/**
* 首次打开抽屉短暂提示可左右滑动
*/
maybeShowSwipeTip() {
let shown = '';
try {
shown = uni.getStorageSync(SWIPE_TIP_STORAGE_KEY);
} catch (e) {
shown = '';
}
if (shown) return;
uni.showToast({
title: '左右滑动可切换标签',
icon: 'none',
duration: 2000,
});
try {
uni.setStorageSync(SWIPE_TIP_STORAGE_KEY, '1');
} catch (e) {
// ignore
}
},
async loadDetail() {
this.loading = true;
try {
const wrap = await getUserPatientDetail(this.upId);
if (wrap && wrap.ok && wrap.data) {
this.profile = {
user: wrap.data.user || {},
patient: wrap.data.patient || {},
};
this.health = wrap.data.health_inquiry || null;
}
this.calcSwiperHeight();
this.$nextTick(() => {
this.maybeShowSwipeTip();
});
} finally {
this.loading = false;
}
},
/** 点击顶部 Tab */
selectTab(index) {
if (this.tabSwiperChanging) return;
const tab = this.tabs[index];
if (!tab) return;
this.tabIndex = index;
this.switchTab(tab.key);
},
/** swiper 滑动切换 */
onTabSwiperChange(e) {
const index = e && e.detail ? e.detail.current : 0;
const tab = this.tabs[index];
if (!tab || tab.key === this.activeTab) {
this.tabIndex = index;
return;
}
this.tabSwiperChanging = true;
this.tabIndex = index;
this.switchTab(tab.key);
this.$nextTick(() => {
this.tabSwiperChanging = false;
});
},
/** 切换 Tab 并懒加载对应列表 */
async switchTab(key) {
this.activeTab = key;
const idx = this.tabs.findIndex((t) => t.key === key);
if (idx >= 0 && this.tabIndex !== idx) {
this.tabIndex = idx;
}
if (key === 'info') return;
if (key === 'register' && !this.registerList.length) {
await this.loadRegisters();
} else if (key === 'prescription' && !this.prescriptionList.length) {
await this.loadPrescriptions();
} else if (key === 'order' && !this.orderList.length) {
await this.loadOrders();
}
},
async loadRegisters() {
this.tabLoading = true;
try {
const wrap = await getUserPatientRegisterList(this.upId);
this.registerList = (wrap && wrap.ok && wrap.data && wrap.data.items) || [];
} finally {
this.tabLoading = false;
}
},
async loadPrescriptions() {
this.tabLoading = true;
try {
const wrap = await getUserPatientPrescriptionList(this.upId);
this.prescriptionList = (wrap && wrap.ok && wrap.data && wrap.data.items) || [];
} finally {
this.tabLoading = false;
}
},
async loadOrders() {
this.tabLoading = true;
try {
const wrap = await getUserPatientOrderList(this.upId);
this.orderList = (wrap && wrap.ok && wrap.data && wrap.data.items) || [];
} finally {
this.tabLoading = false;
}
},
/**
* 挂号行:关抽屉后进挂号详情(与详情页一致)
*/
onRecordClick(item, tabKey) {
if (tabKey !== 'register') return;
const rid = Number((item && item.id) || 0);
if (!rid) {
uni.showToast({ title: '缺少挂号信息', icon: 'none' });
return;
}
try {
uni.setStorageSync('user_patient_register_detail', item || {});
} catch (e) { /* ignore */ }
this.close();
uni.navigateTo({
url: `/subPackages/sub_business_shared/user-patient/register-detail?id=${rid}`,
});
},
},
};
</script>
<style lang="scss" scoped>
.drawer {
height: 80vh;
background: #f9fafb;
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
}
.drawer-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 32rpx;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
flex-shrink: 0;
}
.title { font-size: 30rpx; font-weight: 600; color: #111827; }
.close { font-size: 26rpx; color: #6acdbb; }
.profile-card {
margin: 20rpx 24rpx 0;
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
flex-shrink: 0;
}
.profile-row { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
.avatar { width: 80rpx; height: 80rpx; border-radius: 50%; background: #e5e7eb; }
.meta { display: flex; flex-direction: column; min-width: 0; }
.name { font-size: 28rpx; font-weight: 600; color: #1f2937; }
.sub { font-size: 22rpx; color: #9ca3af; margin-top: 4rpx; }
.patient-block { padding-top: 12rpx; border-top: 1rpx solid #f3f4f6; }
.patient-head {
display: flex; align-items: center; justify-content: space-between; gap: 16rpx;
}
.patient-head-left { display: flex; align-items: center; gap: 12rpx; min-width: 0; flex: 1; }
.p-name { font-size: 28rpx; font-weight: 600; color: #111827; }
.sex-tag {
font-size: 20rpx; padding: 2rpx 12rpx; border-radius: 8rpx;
background: #e8f5f2; color: #2a9d8f;
}
.sex-tag.male { background: #e8f0fe; color: #3b82f6; }
.sex-tag.female { background: #fce7f3; color: #db2777; }
.callback-btn {
flex-shrink: 0;
padding: 8rpx 22rpx;
border-radius: 24rpx;
border: 1rpx solid #6acdbb;
background: #e8f5f2;
}
.callback-btn-text { font-size: 24rpx; color: #1a9b88; font-weight: 500; }
.p-sub { display: block; font-size: 22rpx; color: #6b7280; margin-top: 6rpx; }
.tabs {
display: flex;
background: #fff;
margin: 16rpx 24rpx 0;
border-radius: 16rpx 16rpx 0 0;
padding: 0 8rpx;
border-bottom: 1rpx solid #eef2f1;
flex-shrink: 0;
}
.tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 0 14rpx;
position: relative;
}
.tab-text { font-size: 26rpx; color: #6b7280; }
.tab.on .tab-text { color: #1a9b88; font-weight: 600; }
.tab-bar {
position: absolute; bottom: 0; width: 48rpx; height: 4rpx;
border-radius: 4rpx; background: #6acdbb;
}
.tab-swiper {
width: 100%;
padding: 0 24rpx;
box-sizing: border-box;
}
.tab-scroll { width: 100%; box-sizing: border-box; }
.health-card {
background: #fff; border-radius: 0 0 16rpx 16rpx; padding: 8rpx 20rpx 20rpx;
}
.section-title {
display: block; font-size: 26rpx; font-weight: 600; color: #1f2937;
padding: 16rpx 0 8rpx;
}
.info-row {
display: flex; justify-content: space-between; align-items: flex-start;
padding: 16rpx 0; border-bottom: 1rpx solid #f3f4f6; gap: 24rpx;
}
.info-row:last-child { border-bottom: none; }
.info-label { font-size: 24rpx; color: #9ca3af; flex-shrink: 0; }
.info-value { font-size: 24rpx; color: #374151; text-align: right; flex: 1; word-break: break-all; }
.info-value.danger { color: #dc2626; font-weight: 600; }
.empty-inline { text-align: center; color: #9ca3af; padding: 40rpx 0; font-size: 24rpx; }
.record-card {
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 12rpx;
display: flex;
align-items: center;
gap: 12rpx;
}
.record-main { flex: 1; min-width: 0; }
.r-title { display: block; font-size: 26rpx; font-weight: 600; color: #111827; }
.r-sub { display: block; font-size: 22rpx; color: #6b7280; margin-top: 6rpx; }
.arrow { font-size: 36rpx; color: #d1d5db; line-height: 1; }
.empty { text-align: center; color: #9ca3af; padding: 48rpx 0; font-size: 26rpx; }
</style>