431 lines
11 KiB
Vue
431 lines
11 KiB
Vue
<template>
|
||
<view class="content">
|
||
<view class="navbox">
|
||
<view class="status_bar"></view>
|
||
<uni-nav-bar
|
||
left-icon="left"
|
||
title="患者列表压测"
|
||
@clickLeft="goBack"
|
||
color="#8B2316"
|
||
:border="false"
|
||
backgroundColor="#eee"
|
||
>
|
||
<template #right>
|
||
<view class="nav-right">
|
||
<uni-icons type="search" size="24" color="#8B2316"></uni-icons>
|
||
</view>
|
||
</template>
|
||
</uni-nav-bar>
|
||
</view>
|
||
|
||
<view class="patient-list">
|
||
<view class="perf-toolbar">
|
||
<view class="perf-btn primary" @click="loadRealData">加载真实数据</view>
|
||
<view class="perf-btn" @click="loadRealData(true)">强制刷新</view>
|
||
<view class="perf-btn" @click="clearData">清空</view>
|
||
</view>
|
||
<view class="perf-tip">当前数据量: {{ patientList.length }},分组: {{ patientGroups.length }},当前字母: {{ activeLetter || '-' }}</view>
|
||
|
||
<view class="listbox patient-building" v-if="patientDataLoading && !loadFinish">
|
||
<text class="patient-building-text">正在生成并整理模拟数据...</text>
|
||
</view>
|
||
|
||
<view class="listbox" v-show="patientList.length > 0">
|
||
<scroll-view
|
||
class="groups-scroll"
|
||
scroll-y="true"
|
||
:scroll-into-view="scrollIntoViewId"
|
||
:scroll-with-animation="true"
|
||
@scroll="onGroupsScroll"
|
||
v-show="loadFinish"
|
||
>
|
||
<view class="special-actions">
|
||
<view class="action-item">
|
||
<view class="action-icon new-patient"></view>
|
||
<text class="action-text">新的患者(模拟)</text>
|
||
<uni-icons type="right" size="20" color="#999"></uni-icons>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="group-section" v-for="group in patientGroups" :key="group.letter" :id="`anchor-${group.letter}`">
|
||
<view class="group-header">{{ group.letter }}</view>
|
||
<view class="patient-item" v-for="item in group.items" :key="item.uuid || item.id">
|
||
<view class="patient-avatar avatar-placeholder"></view>
|
||
<view class="patient-info">
|
||
<text class="patient-name">{{ item.nickname || item.realName }}</text>
|
||
</view>
|
||
<view class="patient-status">
|
||
<text class="follow-date">随访于{{ item.joinDateYMD }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="letter-index" v-if="loadFinish && indexList.length > 0">
|
||
<view class="letter-item" v-for="letter in indexList" :key="letter" :class="{ active: activeLetter === letter }" @click="onLetterTap(letter)">
|
||
{{ letter }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="listbox" v-show="patientList.length === 0 && !patientDataLoading">
|
||
<empty></empty>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { nextTick, ref, shallowRef } from 'vue';
|
||
import { onBackPress } from '@dcloudio/uni-app';
|
||
import dayjs from 'dayjs';
|
||
import pinyin from 'pinyin';
|
||
import api from '@/api/api.js';
|
||
|
||
const patientList = ref([]);
|
||
const patientDataLoading = ref(false);
|
||
const loadFinish = ref(false);
|
||
|
||
const firstLetterCache = new Map();
|
||
const LETTER_ORDER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||
const GROUP_BUILD_CHUNK_SIZE = 180;
|
||
const indexList = ref([]);
|
||
const patientGroups = shallowRef([]);
|
||
const allPatientGroups = shallowRef([]);
|
||
const activeLetter = ref('');
|
||
const scrollIntoViewId = ref('');
|
||
const groupScrollRanges = ref([]);
|
||
|
||
const sortLetter = (a, b) => {
|
||
if (a === '#') return 1;
|
||
if (b === '#') return -1;
|
||
const ai = LETTER_ORDER.indexOf(a);
|
||
const bi = LETTER_ORDER.indexOf(b);
|
||
if (ai === -1 && bi === -1) return String(a).localeCompare(String(b));
|
||
if (ai === -1) return 1;
|
||
if (bi === -1) return -1;
|
||
return ai - bi;
|
||
};
|
||
|
||
const formatYMD = (input) => {
|
||
if (!input) return '';
|
||
const d = dayjs(input);
|
||
return d.isValid() ? d.format('YYYY-MM-DD') : '';
|
||
};
|
||
|
||
const getFirstLetter = (name = '') => {
|
||
const firstChar = String(name).trim().charAt(0);
|
||
if (!firstChar) return '#';
|
||
if (/^[A-Za-z]$/.test(firstChar)) return firstChar.toUpperCase();
|
||
if (firstLetterCache.has(firstChar)) return firstLetterCache.get(firstChar);
|
||
try {
|
||
const py = pinyin(firstChar, { style: pinyin.STYLE_NORMAL })?.[0]?.[0] || '';
|
||
const letter = py ? py.charAt(0).toUpperCase() : '#';
|
||
firstLetterCache.set(firstChar, letter);
|
||
return letter;
|
||
} catch (error) {
|
||
return '#';
|
||
}
|
||
};
|
||
|
||
const rebuildIndexList = () => {
|
||
indexList.value = allPatientGroups.value
|
||
.map(g => String(g?.letter || '').toUpperCase())
|
||
.filter((letter, index, arr) => (letter === '#' || /^[A-Z]$/.test(letter)) && arr.indexOf(letter) === index)
|
||
.sort(sortLetter);
|
||
activeLetter.value = indexList.value[0] || '';
|
||
};
|
||
const onLetterTap = (letter) => {
|
||
if (!letter) return;
|
||
activeLetter.value = letter;
|
||
const targetId = `anchor-${letter}`;
|
||
if (scrollIntoViewId.value === targetId) {
|
||
scrollIntoViewId.value = '';
|
||
nextTick(() => {
|
||
scrollIntoViewId.value = targetId;
|
||
});
|
||
return;
|
||
}
|
||
scrollIntoViewId.value = targetId;
|
||
};
|
||
const rpxToPx = (rpx) => {
|
||
const { screenWidth } = uni.getSystemInfoSync();
|
||
return Math.round((screenWidth / 750) * rpx);
|
||
};
|
||
const buildGroupScrollRanges = () => {
|
||
const actionHeight = rpxToPx(120); // action-item 高度近似
|
||
const headerHeight = rpxToPx(80); // group-header 高度近似
|
||
const itemHeight = rpxToPx(150); // patient-item 高度近似
|
||
let cursor = actionHeight;
|
||
groupScrollRanges.value = patientGroups.value.map(group => {
|
||
const groupHeight = headerHeight + (group.items?.length || 0) * itemHeight;
|
||
const range = {
|
||
letter: group.letter,
|
||
start: cursor,
|
||
end: cursor + groupHeight
|
||
};
|
||
cursor += groupHeight;
|
||
return range;
|
||
});
|
||
};
|
||
const onGroupsScroll = (e) => {
|
||
const top = Number(e?.detail?.scrollTop || 0);
|
||
if (!groupScrollRanges.value.length) return;
|
||
let current = groupScrollRanges.value[0].letter;
|
||
for (let i = 0; i < groupScrollRanges.value.length; i++) {
|
||
const range = groupScrollRanges.value[i];
|
||
if (top >= range.start) {
|
||
current = range.letter;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
activeLetter.value = current;
|
||
};
|
||
|
||
const waitForNextFrame = () => new Promise(resolve => setTimeout(resolve, 0));
|
||
const buildGroupsFromPatients = async () => {
|
||
const map = new Map();
|
||
const source = patientList.value || [];
|
||
for (let i = 0; i < source.length; i++) {
|
||
const p = source[i] || {};
|
||
const normalized = {
|
||
...p,
|
||
joinDateYMD: formatYMD(p.join_date)
|
||
};
|
||
const first = getFirstLetter(normalized.nickname || normalized.realName);
|
||
const letter = /^[A-Z]$/.test(first) ? first : '#';
|
||
if (!map.has(letter)) map.set(letter, []);
|
||
map.get(letter).push(normalized);
|
||
if (i > 0 && i % GROUP_BUILD_CHUNK_SIZE === 0) {
|
||
await waitForNextFrame();
|
||
}
|
||
}
|
||
const letters = Array.from(map.keys()).sort(sortLetter);
|
||
allPatientGroups.value = letters.map(l => ({ letter: l, items: map.get(l) }));
|
||
patientGroups.value = allPatientGroups.value;
|
||
rebuildIndexList();
|
||
buildGroupScrollRanges();
|
||
};
|
||
const loadRealData = async (force = false) => {
|
||
if (patientDataLoading.value) return;
|
||
if (!force && patientList.value.length > 0) return;
|
||
patientDataLoading.value = true;
|
||
loadFinish.value = false;
|
||
try {
|
||
const t1 = Date.now();
|
||
const res = await api.patientListByGBK();
|
||
if (res?.code !== 1) {
|
||
uni.showToast({ title: '获取数据失败', icon: 'none' });
|
||
return;
|
||
}
|
||
patientList.value = res.data || [];
|
||
await buildGroupsFromPatients();
|
||
loadFinish.value = true;
|
||
const t2 = Date.now();
|
||
uni.showToast({
|
||
title: `加载完成 ${patientList.value.length}条/${t2 - t1}ms`,
|
||
icon: 'none',
|
||
duration: 1500
|
||
});
|
||
} finally {
|
||
patientDataLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const clearData = () => {
|
||
patientList.value = [];
|
||
patientGroups.value = [];
|
||
allPatientGroups.value = [];
|
||
indexList.value = [];
|
||
groupScrollRanges.value = [];
|
||
activeLetter.value = '';
|
||
scrollIntoViewId.value = '';
|
||
loadFinish.value = false;
|
||
};
|
||
|
||
const goBack = () => {
|
||
plus.runtime.quit();
|
||
};
|
||
|
||
onBackPress(() => {
|
||
plus.runtime.quit();
|
||
return true;
|
||
});
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.content {
|
||
background-color: #f5f5f5;
|
||
height: 100vh;
|
||
overflow-y: hidden;
|
||
}
|
||
.nav-right {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.patient-list {
|
||
width: 100%;
|
||
margin-top: calc(var(--status-bar-height) + 44px);
|
||
height: calc(100vh - var(--status-bar-height) - 44px);
|
||
overflow: hidden;
|
||
background-color: #fff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.perf-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 16rpx 20rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
background: #fff;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 5;
|
||
}
|
||
.perf-btn {
|
||
padding: 10rpx 16rpx;
|
||
background: #f2f2f2;
|
||
border-radius: 8rpx;
|
||
font-size: 24rpx;
|
||
color: #555;
|
||
margin-right: 12rpx;
|
||
}
|
||
.perf-btn.primary {
|
||
background: #8B2316;
|
||
color: #fff;
|
||
}
|
||
.perf-tip {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
padding: 8rpx 20rpx 14rpx;
|
||
}
|
||
.listbox {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
.patient-building {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.patient-building-text {
|
||
font-size: 28rpx;
|
||
color: #999;
|
||
margin-top: 60rpx;
|
||
}
|
||
.groups-scroll {
|
||
height: 100%;
|
||
}
|
||
.special-actions {
|
||
background-color: #fff;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
.action-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 30rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
.action-icon {
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-right: 20rpx;
|
||
}
|
||
.new-patient {
|
||
background-color: #8B2316;
|
||
}
|
||
.action-text {
|
||
flex: 1;
|
||
font-size: 32rpx;
|
||
color: #333;
|
||
}
|
||
.group-header {
|
||
padding: 20rpx 30rpx;
|
||
background-color: #e4e4e4;
|
||
font-size: 36rpx;
|
||
color: #666;
|
||
}
|
||
.patient-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 30rpx 80rpx 20rpx 30rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
background-color: #fff;
|
||
}
|
||
.patient-avatar {
|
||
width: 90rpx;
|
||
height: 90rpx;
|
||
border-radius: 10rpx;
|
||
margin-right: 20rpx;
|
||
}
|
||
.avatar-placeholder {
|
||
position: relative;
|
||
background: #e0e0e0;
|
||
border-radius: 50%;
|
||
}
|
||
.avatar-placeholder::before {
|
||
content: '';
|
||
position: absolute;
|
||
width: 28rpx;
|
||
height: 28rpx;
|
||
border-radius: 50%;
|
||
background: #ffffff;
|
||
top: 16rpx;
|
||
left: 31rpx;
|
||
}
|
||
.avatar-placeholder::after {
|
||
content: '';
|
||
position: absolute;
|
||
width: 56rpx;
|
||
height: 30rpx;
|
||
border-radius: 28rpx 28rpx 12rpx 12rpx;
|
||
background: #ffffff;
|
||
left: 17rpx;
|
||
bottom: 14rpx;
|
||
}
|
||
.patient-info {
|
||
flex: 1;
|
||
}
|
||
.patient-name {
|
||
font-size: 32rpx;
|
||
color: #333;
|
||
font-weight: 500;
|
||
}
|
||
.patient-status {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
flex-direction: column;
|
||
}
|
||
.follow-date {
|
||
margin-top: 8rpx;
|
||
font-size: 24rpx;
|
||
color: #666;
|
||
}
|
||
.letter-index {
|
||
position: fixed;
|
||
right: 10rpx;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
background-color: rgba(255, 255, 255, 0.9);
|
||
border-radius: 20rpx;
|
||
padding: 10rpx 5rpx;
|
||
z-index: 999;
|
||
}
|
||
.letter-item {
|
||
padding: 8rpx 12rpx;
|
||
font-size: 24rpx;
|
||
color: #666;
|
||
text-align: center;
|
||
border-radius: 10rpx;
|
||
}
|
||
.letter-item.active {
|
||
color: #8B2316;
|
||
font-weight: 600;
|
||
}
|
||
</style>
|